From bc9c94c6559f6a06f8f29294087a800da4013c26 Mon Sep 17 00:00:00 2001 From: iap Date: Fri, 23 Oct 2026 18:47:35 +0800 Subject: [PATCH 1/3] Release SSHScript 6.1.0 public Console integration --- .gitignore | 2 + AI_GUIDE.md | 25 +- API_GUIDE.md | 30 +- CHANGELOG.md | 25 +- CONSOLE.md | 25 +- EMBEDDED_CONSOLE.md | 200 +++++ MANIFEST.in | 5 + README.md | 43 +- RELEASING.md | 4 +- VERSIONING.md | 65 +- examples/embedded_agent.py | 36 + examples/embedded_terminal.py | 21 + pyproject.toml | 2 +- skills/sshscript/SKILL.md | 28 +- skills/sshscript/references/agent-guide.md | 46 +- src/sshscript/__init__.py | 3 + src/sshscript/__init__.pyi | 2 + src/sshscript/_version.py | 2 +- src/sshscript/interactive.py | 759 +++++++++++++----- src/sshscript/interactive.pyi | 71 ++ src/sshscript/sshscript.py | 4 +- .../unittest/test_embedded_console.py | 471 +++++++++++ .../unittest/test_interactive_console.py | 172 ++-- .../unittest/test_native_auth_runner.py | 4 +- .../test_native_console_authentication.py | 29 +- .../unittest/test_openssh_integration.py | 11 +- src/sshscript/unittest/test_public_types.py | 3 +- tools/check_public_types.py | 7 +- tools/prepare_release.py | 4 +- tools/sync_ai_docs.py | 7 + tools/sync_api_guide.py | 1 + 31 files changed, 1734 insertions(+), 373 deletions(-) create mode 100644 EMBEDDED_CONSOLE.md create mode 100644 examples/embedded_agent.py create mode 100644 examples/embedded_terminal.py create mode 100644 src/sshscript/interactive.pyi create mode 100644 src/sshscript/unittest/test_embedded_console.py diff --git a/.gitignore b/.gitignore index cbea387..5b974a7 100644 --- a/.gitignore +++ b/.gitignore @@ -121,3 +121,5 @@ htmlcov/ !/unittest/test_update_check.py !/unittest/test_interactive_console.py + +!/unittest/test_embedded_console.py diff --git a/AI_GUIDE.md b/AI_GUIDE.md index cac68fb..837af92 100644 --- a/AI_GUIDE.md +++ b/AI_GUIDE.md @@ -1,10 +1,9 @@ # SSHScript for AI agents -**Documentation contract: SSHScript 6.0.0; Python 3.11+.** +**Documentation contract: SSHScript 6.1.0; Python 3.11+.** -This guide targets the 6.0.0 source contract and verified local wheels. -[5.0.0 remains the latest published package](https://pypi.org/project/sshscript/5.0.0/); -source publication and CI do not upload 6.0.0 to PyPI. +This guide targets SSHScript 6.1.0 and its public Console integration API. +Install `sshscript==6.1.0` and verify the imported package version. For nested SSH, provision known_hosts on each calling host: `"parent"` is the default. Read the 4.0.2-to-5.0 migration before reusing older automation. @@ -19,14 +18,22 @@ workflow. Its folder contains the supporting guide; copy the entire It teaches SSHScript usage, not development of SSHScript itself or a particular recipe library. Agents without skill support can read these Markdown files. -## Interactive console (6.0) +## Borrowed Session integration (unified Console API) + +Use `from sshscript import Console` and `with Console(existing_session)` for an +existing authorized connection. Read [the full host contract](EMBEDDED_CONSOLE.md) +before driving it. Require structured completion/exit status and confirmed reuse; +keep host account policy and credentials in the resolver. A prompt/silence is not +a success signal. See the bundled skill for the matching agent guidance. + +## Interactive console (6.1) For step-by-step use from a human or UI agent's retained terminal, see the [console guide](CONSOLE.md) and the agent guide's -[console instructions](skills/sshscript/references/agent-guide.md#interactive-console-60). +[console instructions](skills/sshscript/references/agent-guide.md#interactive-console-61). Run `python3 -B sshscript.py console` from the matching development checkout; -an installed 6.0.0 wheel provides `sshscript console`. Published 5.0.0 does not -include this console. +an installed 6.1.0 wheel provides `sshscript console`. Use public +Console(session) for host integration; older 6.0 console internals are removed. Input remains Python with dollar commands: `$hostname` executes a command, `print($.stdout)` reads its result, and variables persist between inputs. @@ -109,4 +116,4 @@ Validate links, skill structure, example behavior, source/ZIP equality and the rendered website. Source and website publishing are separate operations; copying files does not publish either of them. -*Last modified: 2026-10-22 23:24:55* +*Last modified: 2026-10-23 18:39:31* diff --git a/API_GUIDE.md b/API_GUIDE.md index e8125c1..98643fb 100644 --- a/API_GUIDE.md +++ b/API_GUIDE.md @@ -1,7 +1,7 @@ # Recommended SSHScript API This is the canonical entry point for new code and AI-generated examples. -**Current source contract: 6.0.0**, Python 3.11+. Sections marked **4.0** require +**Current release contract: 6.1.0**, Python 3.11+. Sections marked **4.0** require SSHScript 4.0.2 or newer; installing `sshscript==3.1.5` does not provide them. Examples labelled 3.1.5 remain verified compatibility examples for that baseline. Pin the exact artifact version and record it with execution diagnostics when @@ -31,8 +31,8 @@ identifiers and handoff reports are future work, not a current API guarantee. ## One command, one retained result (4.0; compatible with 3.1.5) -For this source contract, install a verified 6.0.0 wheel. Once published, pin -`sshscript==6.0.0`; the source push does not itself upload a PyPI package. +Install `sshscript==6.1.0` for this contract. Verify the installed version and +artifact before operational use; source publication alone does not install it. For reproducing only the historical compatibility baseline, explicitly install `sshscript==3.1.5`; the 4.0 sections require the current release. An argv list is one command, not a batch. Shell-looking argument text stays data. @@ -235,7 +235,7 @@ print(result.stdout) ## 4.0: settings and managed jobs -Use SSHScript 6.0.0 for the following examples. CLI `-v`, `--stderr`, and +Use SSHScript 6.1.0 for the following examples. CLI `-v`, `--stderr`, and `--debug` override root defaults only for that execution. Child Sessions copy their parent's settings. `set()` validates before changing anything; `get()` returns a copy. Explicit command options override Session policy. @@ -254,7 +254,7 @@ the settings dictionary at creation; the policy object itself is shared, so custom policies with mutable state must account for reuse. Changing the setting does not reconfigure existing connections. - + ```python from contextlib import closing import paramiko @@ -276,7 +276,7 @@ semantics; do not combine it with `command_timeout`. For an indefinite command such as tcpdump, use `start(timeout=None)`. This safe local example uses a small Python process instead of capturing network traffic: - + ```python from contextlib import closing import sys @@ -315,7 +315,7 @@ legacy SSH behavior which appended a newline to strings: if a consumer needs a line, migrate `input=password` to `input=password + "\n"` explicitly. Published 3.1.5 still has the legacy SSH behavior. - + ```python from contextlib import closing import sys @@ -384,7 +384,7 @@ or unpack exactly `stdout, stderr, exitcode`. This changes the released 3.1.5 console contract: old `stdout, stderr = shell(command)` must be migrated. Shell calls accept command strings; argv execution belongs to Session. - + ```python from contextlib import closing from sshscript import Session @@ -440,7 +440,7 @@ matching: `iter_stdout()` yields chunks, not packets or lines. Put `stop()` in `finally`, so condition matches, exceptions, and KeyboardInterrupt all trigger cleanup. This credential-free example uses a continuous Python process: - + ```python from contextlib import closing import sys @@ -505,7 +505,7 @@ the current Bash console's channel, cwd, environment and identity. It does not launch through the underlying Session. Ordinary `console(command)` / `$command` still waits and returns `CommandResult`; no migration is needed for finite commands. - + ```python from contextlib import closing import shlex @@ -765,6 +765,14 @@ The same source is mirrored into the website; edit this file, then use production details remain in the full documentation. +## Embed an existing Session (unified Console API) + +`from sshscript import Console` provides a supported integration surface without +editing reserved variables. `Console(session)` borrows the connection, while +`ownership="owned"` explicitly transfers ownership. Read the complete +[API and lifecycle contract](EMBEDDED_CONSOLE.md) for structured updates, +credentials, identity, PTY limits, exceptions and runnable examples. + ## Interactive console and script completion (6.0) See [the console guide](CONSOLE.md) for the 6.0 Python-first dollar REPL, retained @@ -782,4 +790,4 @@ ScriptResult with exitcode, stopped and message attributes; run_file() returns the integer status. Cleanup failures still propagate. Python SystemExit remains separate from normal script completion. -*Last modified: 2026-10-22 23:24:55* +*Last modified: 2026-10-23 18:39:31* diff --git a/CHANGELOG.md b/CHANGELOG.md index ad13909..e8a45a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## 6.1.0 — 2026-10-23 + +- Add public `Console(existing_session)` for terminal and host/AI-agent interaction + over an existing Session, including target Sessions reached through jumps, + without reconnecting or changing authentication/host-key sources. +- Retain Python variables, shell cwd/environment and interactive program state. + Expose structured submit/poll/input/interrupt/EOF and completion results. +- Add account credential/policy and switch-result hooks, separate connection login + identity from effective shell identity, and expose the environment stack. +- Borrow Sessions by default. Unwind console-owned layers and confirm Session + reuse; initialization/restoration/cleanup failures report unknown explicitly. +- Fix lost prompt readiness after program Ctrl-C. Keep waiting_input after a + protocol match, running for ignored/unfinished interrupts, and real exit status + for exiting programs; support delayed prompt recovery through poll. +- Use one Console implementation for CLI and embedding. Remove old internal + InteractiveConsole/open()/execute() entry points and :help/:quit aliases; use + Console(session), submit(), help/quit and structured status instead. +- Add complete terminal/agent examples and the matching AI skill. OpsFeast reports + local/simulated and browser UI revalidation passed. This is distinct from real + SSH/jump/PAM/su/sudo validation; release CI records its own disposable native tests. +- Development-stage releases do not guarantee backward compatibility unless + explicitly requested. Minor releases may include the differences listed above. + ## 6.0.0 — 2026-10-22 (incompatible source contract) - Add console helpers: help/help(), help(object), exit/exit() to leave one @@ -314,4 +337,4 @@ verified artifacts in the [Unreleased]: https://github.com/iapyeh/sshscript/compare/v3.1.4...release [3.1.4]: https://github.com/iapyeh/sshscript/releases/tag/v3.1.4 -*Last modified: 2026-10-22 23:24:55* +*Last modified: 2026-10-23 18:39:31* diff --git a/CONSOLE.md b/CONSOLE.md index b51af1f..e39ea91 100644 --- a/CONSOLE.md +++ b/CONSOLE.md @@ -1,16 +1,24 @@ -# Interactive console (6.0) +# Interactive console (6.1) -The SSHScript 6.0 console runs from the flat development checkout: +The SSHScript 6.1 console runs from the flat development checkout: ```sh python3 -B sshscript.py console ``` -Use Python 3.11 or newer with the project's dependencies. An installed 6.0.0 -wheel provides `sshscript console`. Published 5.0.0 lacks this feature; this -source version update does not upload a package. No installation into the +Use Python 3.11 or newer with the project's dependencies. Install +`sshscript==6.1.0` for `sshscript console`. No installation into the flat development checkout is required. +## Embed an existing Session (new API) + +Use the public `sshscript.Console` API for borrowed Sessions, host-driven updates, +credential hooks and explicit cleanup/reuse reporting. Read +[the embedded console contract](EMBEDDED_CONSOLE.md) and its executable examples. +The CLI uses the same Console core and ownership contract. Earlier 6.0 CLI +compatibility is not maintained; see the linked contract for removed entry points +and aliases. This guide describes the 6.1.0 release contract. + ## A Python console with dollar syntax Input is Python by default, with SSHScript dollar syntax available in the same @@ -18,7 +26,7 @@ namespace. No Python/shell language-mode switching is required: ```text local [user]> hostname -NameError: name 'hostname' is not defined +NameError: operation failed; inspect error for details. local [user]> hostname = "a Python variable" local [user]> hostname 'a Python variable' @@ -165,7 +173,8 @@ Readiness or silence is not interpreted as a successful process exit. - In an enter() program, Ctrl-C/Ctrl-D are forwarded as raw terminal control characters, without an added newline. Only a detected return to the parent shell closes that program layer; the Python console stays available. -- `:help` and `:quit` remain aliases; `:status` shows the environment stack. +- Use `help`/`help()` and `quit`/`quit()`. The former `:help` and `:quit` aliases + are removed; `:status` shows the environment stack. The Python helper names can be shadowed by assignment, just like Python names; the colon aliases and `control.quit()` remain available. - Python `sys.exit()` retains SystemExit semantics. The interactive boundary @@ -205,4 +214,4 @@ Input now follows Python/dollar syntax throughout; prefix shell commands with `$ Type `help` for a reminder. Operation failures display their type without reprinting credential-bearing input; inspect `error` at the prompt for details. -*Last modified: 2026-10-22 23:24:55* +*Last modified: 2026-10-23 18:39:31* diff --git a/EMBEDDED_CONSOLE.md b/EMBEDDED_CONSOLE.md new file mode 100644 index 0000000..44c0937 --- /dev/null +++ b/EMBEDDED_CONSOLE.md @@ -0,0 +1,200 @@ +# Unified Console API + +**Contract: SSHScript 6.1.0; Python 3.11+.** + +Design and lifecycle contract, written before implementation. + +`from sshscript import Console` exposes the shared Python/dollar console +on an existing `Session`. `Console(session, ownership="borrowed")` opens only a +new Bash channel, without connect(), authentication or Session settings changes. +Jump connections and SSH/SFTP login identity remain owned by the caller. +`ownership="owned"` explicitly transfers the supplied Session (and its children, +not its ancestors) to the console. Ownership begins at construction, including +initialization failure. A local Session is also supported. + +All calls run on the constructing thread. The caller must serialize console and +Session use until close. Python evaluation and lexical with blocks are synchronous; +arbitrary Python cannot be safely preempted. `submit(line)` retains the existing +compiler and namespace. `start_command(command, timeout=60)` starts the existing +persistent-shell foreground job; `poll()` reads incremental output without waiting. +`send_input(text, timeout=...)` replies to an enter() program, appending one newline. +`send_input(text, newline=False)` sends raw text to an enter() program. +Shell jobs reject stdin writes: a late write could reach the parent shell after +the process exits. Use enter() for interactive applications. +`interrupt()` stops a job with bounded recovery, or sends Ctrl-C to a program; +otherwise it discards unfinished Python input. After program Ctrl-C, a matched +program prompt returns `waiting_input` and remains ready across polls, even when +the protocol has consumed the prompt text. A control-read timeout stays `running`; +a later prompt can restore readiness through `poll()`. `error_type=KeyboardInterrupt` +records the interruption attempt, not program completion or failure. If the program +exits, its restored exit status determines `completed` or `failed`. `eof()` leaves program EOF behavior +unchanged; at the Python prompt it closes the console. `leave()` exits one layer, +including the root shell; `close()` unwinds everything. `quit` closes everything. +Python SystemExit is reported as failed input and leaves the console open. +A host exception in `with Console(...)` runs cleanup; cleanup failure is never +suppressed. Initialization failure cleans up the acquired channel. + +Immutable `ConsoleUpdate` records carry state, incremental stdout/stderr, a command result, +safe error type, and Session reuse status. States are `ready`, `incomplete`, +`running`, `waiting_input`, `completed`, `failed`, `closed`. A nonzero exit status, +Python/syntax error, deadline expiry or unconfirmed recovery is failure. `completed` +requires a real job completion record or a restored program exit status, never silence. `waiting_input` means an +enter() prompt matched the configured program protocol; it is not proof of command +success. Silent or timed-out interactive reads remain `running`. A generic process +cannot reveal whether it is waiting for stdin: shell jobs remain `running` until +completion. Polling does not infer readiness from text. +For an ordinary Python cell, completed means evaluation returned without an +uncaught exception. It does not certify side effects or results of arbitrary +Python calls. The exit-status classification applies to managed dollar jobs, +start_command() and restored enter() program results; inspect other returned +Python API results explicitly. Configure enter() prompts +for text programs; full-screen terminal emulation is unsupported. + +`connection` returns the current Session, without changing its login account. +`environments` returns immutable snapshots with kind, host, connection username, +effective username and identity verification. Effective names come from `id -un` +on entry; program layers inherit the last verified name with verification false. +They are observations, not authorization evidence for subsequent arbitrary Python. +Leaving a nested layer restores its parent channel; closing the root channel does +not attempt to preserve that shell's cwd/environment in the Session's later new +commands. A successful borrowed close additionally probes Session command execution +with a finite budget; `session_reuse="confirmed"` means cleanup and that probe +succeeded. `unknown` means the caller must reconcile or discard the connection; +`closed` means an owned Session was closed. No API can guarantee future network +availability or remote child-process termination from a channel close alone. + +`credential_resolver(SwitchRequest)` runs before every managed su/sudo switch, +including explicit credentials. It may reject by raising or return +`SwitchCredentials(password=...)`; None keeps explicit credentials. Request metadata +contains no password. `switch_observer(SwitchOutcome)` receives success/failure, +request, resulting environment and safe error type, never credential/error text. +The resolver implements host configuration, candidate ordering and policy outside +SSHScript. It is called once per attempted switch; there is no automatic retry. +A missing password in non-terminal mode raises `CredentialsRequiredError`; +explicit None requests passwordless authentication. Observer failure propagates; +a successful switch remains active and is visible for cleanup. Explicit password +arguments and arbitrary Python error objects are sensitive application memory. +Do not log input source, namespace/error objects, JobResult.command or arbitrary +program output containing secrets. Use the resolver rather than literals in input. +Hooks cover managed Console.su/sudo and prompt-level dollar calls; arbitrary Python, +raw Session access and lexical script with blocks are trusted code, not a policy +sandbox. Hosts must restrict supplied code if they need enforced authorization. + +`terminal=False` needs no terminal on the Python process. The new shell still uses +a PTY by default for su/sudo and reliable job interruption. `get_pty=False` supports +pipe jobs but cannot safely interrupt a running job; recovery can be unknown. +Programs/password readers may require a PTY. No hidden getpass prompt is used in +non-terminal mode. `terminal=True` enables the terminal frontend of the same Console core +and getpass fallback. Captured event streams and terminal streams are separate; +headless capture is consumed by each returned update. When a custom stdout or +stderr stream is supplied (or terminal=True selects terminal streams), that +stream receives the output and the corresponding update output field is empty. Only one console/redirected +Python execution may run in a process at a time because Python stdout redirection +is process-global. This API does not provide a Python sandbox or an async Python +executor. + +## One core for CLI and host integration + +The CLI and embedding applications use the same public `Console` implementation +in `interactive.py`. There is no legacy InteractiveConsole class, separate embedded +subclass, or compatibility module. The CLI creates an explicit owned local Session; +embedding callers normally borrow the Session they already hold. Both frontends +use submit(), poll(), interrupt(), eof() and the same cleanup and failure policy. +The CLI drains foreground $.start() jobs through poll() before reading another line. + +Earlier 6.0 CLI behavior is not a compatibility target. Internal open()/execute() +entry points are removed: construct Console(session) and call submit(line). +`:help` and `:quit` aliases are removed; use help/help() and quit/quit(). `:status` +remains the environment-stack command. Diagnostics always omit source and arbitrary +exception text, including NameError and SystemExit values. A failed channel remains +owned until cleanup and is reported as unknown; it is not silently popped to an +apparently healthy parent layer. SSH/SFTP authentication and Session command APIs +keep their existing responsibilities. + +This public API is released in SSHScript 6.1.0. During development, minor releases +may include incompatible changes; compatibility is required only when explicitly +requested. Pin the released artifact or record the exact source revision/hash. + +## Executable examples + +Run from the flat source checkout with `PYTHONPATH=. python3 -B` followed by +[examples/embedded_agent.py](examples/embedded_agent.py) or +[examples/embedded_terminal.py](examples/embedded_terminal.py). With the matching +installed wheel the PYTHONPATH setting is unnecessary. Both examples create only +local Sessions; `handover(existing_target)` and `drive(existing_target)` also +accept a host's already-connected target Session, including a jump target. + +Terminal handover, followed by reuse: + +```python +from sshscript import Console + +def handover(existing_target): + with Console(existing_target, terminal=True) as console: + console.interact() + assert console.session_reuse == "confirmed" + return existing_target.exec_command("hostname", check=True) +``` + +Non-terminal Python/dollar cells and asynchronous shell output: + +```python +import time +from sshscript import Console + +def drive(existing_target): + with Console(existing_target) as console: + assert console.submit("value = 42").state == "completed" + assert console.submit("$export TASK=value; cd /tmp").state == "completed" + update = console.start_command('printf "%s" "$TASK"', timeout=3) + while True: + consume(update.stdout, update.stderr) # host callback + if update.state != "running": + break + time.sleep(.03) + update = console.poll() + if update.state != "completed": + raise RuntimeError("command failed: " + str(update.error_type)) + assert update.result.exitcode == 0 + return existing_target.exec_command("printf reusable", check=True) +``` + +For a text program, call `console.enter("python3 -q -u", prompt=">>>", exit="quit()")`, +then `console.send_input("print(42)", timeout=2)`. A prompt response produces +`waiting_input`; program exit restores the parent and supplies `result.exitcode`. +`poll()` can observe a program's later prompt/exit after a timed-out read or raw +send. Program results snapshot the final input buffers; retain incremental events +for the full conversation. Job results snapshot the bounded whole command output. +`poll()` may perform bounded shell recovery when an enter() program has exited; +it does not block on a running shell job. Program restoration uses the existing +console exit budget (up to 60 seconds), unlike immediate job polling. + +Credential and policy integration (no host configuration parser in SSHScript): + +```python +from sshscript import Console, SwitchCredentials + +def resolve(request): + host_policy.require_switch(request.environment.host, request.kind, request.username) + # Return None only when keeping an explicit password argument. Returning a + # SwitchCredentials object with password=None deliberately tries passwordless. + return SwitchCredentials(secret_store.password_for(request.username)) + +with Console(existing_target, credential_resolver=resolve, + switch_observer=audit_switch_metadata) as console: + console.su("operator") + print(console.environments[-1]) + console.leave() +``` + +The host supplies `consume`, `host_policy`, `secret_store` and +`audit_switch_metadata`; the linked executable examples need none of these. +Resolver/observer exceptions from direct method calls propagate. `submit()` reports +safe error types instead. Cleanup errors always raise `ConsoleCleanupError` with +`session_reuse="unknown"`; repeated close reports the same failure. Initialization +raises `ConsoleInitializationError` with unknown reuse, even if channel cleanup +appeared successful. If the host body and cleanup both fail, both errors are +preserved in a BaseExceptionGroup. Do not reuse an unknown Session on the strength +of a prompt or a successful command from an unrelated shell. + +*Last modified: 2026-10-23 18:39:31* diff --git a/MANIFEST.in b/MANIFEST.in index c048012..625023d 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -60,3 +60,8 @@ recursive-include src/sshscript *.pyi include src/sshscript/unittest/test_interactive_console.py include CONSOLE.md + +include src/sshscript/unittest/test_embedded_console.py +include EMBEDDED_CONSOLE.md +include examples/embedded_terminal.py +include examples/embedded_agent.py diff --git a/README.md b/README.md index 8205730..d38226c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,11 @@ # SSHScript -SSHScript 6.0.0: [interactive console and script completion](CONSOLE.md). +**Development status:** SSHScript is still under active development. Backward +compatibility is not maintained at this stage. APIs, CLI behavior, configuration +and data formats may change; we provide only brief notes on major differences. +Compatibility work is done only when explicitly requested by the maintainer. + +SSHScript 6.1.0: [borrow an existing Session for interactive operation](EMBEDDED_CONSOLE.md). Run `python3 -B sshscript.py console` from the development checkout. @@ -23,10 +28,9 @@ output and status, distinguish failure from timeout, and make cleanup and unknown termination explicit. A successful command exit is evidence about that command; verify the deployment or other application outcome separately. -**Source contract: 6.0.0.** The release branch and local artifacts target this -version; this source update does not publish a package or create a release tag. -**Latest published release:** [5.0.0](https://github.com/iapyeh/sshscript/releases/tag/v5.0.0) -(Production/Stable) · **Python:** 3.11 or newer · **Tested:** Python +**Release contract: 6.1.0.** Install the matching distribution and verify its version. +[Release notes](https://github.com/iapyeh/sshscript/releases/tag/v6.1.0) +(development-stage project) · **Python:** 3.11 or newer · **Tested:** Python 3.11–3.14 on Linux and macOS [Documentation](https://iapyeh.github.io/sshscript/v3a/) · @@ -35,13 +39,14 @@ version; this source update does not publish a package or create a release tag. [Security](https://github.com/iapyeh/sshscript/security/policy) · [Support](https://github.com/iapyeh/sshscript/blob/release/SUPPORT.md) -**Version 6.0.0** adds a Python-first interactive console and normal script -completion through `$.stop()`. It removes `$.exit()`, `$.break()` and their old -control exceptions; review the [6.0 migration](VERSIONING.md#60-console-and-script-completion-migration). -It retains the 5.0 calling-host default for host-key sources. Provision its known_hosts file or -select `known_hosts="local"` when migrating existing nested connections. -Upgrade 3.1.5 automation using the migration guide and application regressions. -See [version policy and migration](VERSIONING.md) before upgrading automation. +**Version 6.1.0** adds public `Console(existing_session)` integration for terminals, +host applications and AI agents. Python variables, shell state and text programs +persist; structured updates distinguish readiness, execution, failure and cleanup. +Borrowed Sessions remain owned by the host. Credential/policy hooks and verified +Session reuse keep integration explicit. Read the [integration contract](EMBEDDED_CONSOLE.md). +Earlier 6.0 console internals, open()/execute() entry points and :help/:quit aliases +are removed; use Console(session), submit(), help/quit and structured updates. +The 5.0 host-key source and SSH/SFTP login identity contracts remain unchanged. ## AI agent ready @@ -130,7 +135,7 @@ pin SSHScript and all transitive dependencies in your application's lock file. To install this release explicitly: ```sh -python3 -m pip install /path/to/sshscript-6.0.0-py3-none-any.whl +python3 -m pip install "sshscript==6.1.0" ``` Use `python3 -m pip install --upgrade sshscript` to upgrade. The optional @@ -139,11 +144,9 @@ it never installs an update by itself. ## 60-second local quickstart -For 6.0.0, install a verified local wheel as shown above until package publication. -After publication, pin `sshscript==6.0.0` instead. The latest published 5.0.0 -package does not contain the new console. +Install `sshscript==6.1.0` for the Console integration documented here. -This example runs on 6.0.0 and retains compatibility with the 3.1.5 command API. +This example runs on 6.1.0 and retains compatibility with the 3.1.5 command API. The metadata below records that older compatibility baseline. @@ -589,4 +592,8 @@ for maintainer-only release steps. SSHScript is released under the [MIT License](https://github.com/iapyeh/sshscript/blob/release/LICENSE.txt). -*Last modified: 2026-10-22 23:24:55* +For embedding an existing connected Session, see the new public +[Console integration API](EMBEDDED_CONSOLE.md). CLI and embedding applications +share one implementation; earlier 6.0 CLI compatibility is not maintained. + +*Last modified: 2026-10-23 18:39:31* diff --git a/RELEASING.md b/RELEASING.md index 70a16ee..c7db347 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -4,7 +4,7 @@ Python 3.11 or newer is required. The development checkout is a flat source tree Install development tools with `python -m pip install 'paramiko>=2.11,<5' 'packaging>=21' build twine`. Run `python tools/run_checks.py` from working_branch. Run `python tools/check_release.py --output /tmp/sshscript-candidate-UNIQUE` to build a clean allowlisted source distribution, build its wheel, verify the exact public test/instruction list in the sdist, rerun the credential-free gates from that extracted sdist, check metadata and test the installed wheel. The output directory must not exist. Local preparation includes current files, including uncommitted changes; only committed inputs are available in CI. -Review and commit the intended development changes before any explicitly authorized merge into src/main. Follow [VERSIONING.md](VERSIONING.md): edit only working_branch/_version.py on the working_branch branch. Never edit src/main directly. Development builds use distinct .devN versions; incompatible public execution contracts target the next major release. Promotion to a stable version is a separately authorized action after release validation, not an automatic patch bump on merge. Version imports must refer to _version.py; build and upload tools never edit versions. +Review and commit the intended development changes before any explicitly authorized merge into src/main. Follow [VERSIONING.md](VERSIONING.md): edit only working_branch/_version.py on the working_branch branch. Never edit src/main directly. Development builds use distinct .devN versions. During active development, minor releases may include incompatible changes; explicitly select the release version with the maintainer and briefly document major differences. Promotion to a stable version is a separately authorized action after release validation, not an automatic patch bump on merge. Version imports must refer to _version.py; build and upload tools never edit versions. From git/, run `sh update_from_src.sh` to synchronize the integrated src tree. The explicit allowlist preserves unrelated files; review deletions separately when retiring modules. Review the diff, including newly created files. Run `python tools/run_checks.py` and `python tools/check_release.py --output /tmp/sshscript-release-UNIQUE` in git/. Commit reviewed release files, then verify a clean checkout of that commit before tagging or uploading. CI uses the same checks in both layouts on Linux/macOS and Python 3.11–3.14. @@ -61,3 +61,5 @@ from its root. These credential-free gates can skip real OpenSSH tests. Such skips are not proof of native SSH/PAM/su/sudo compatibility; the separate OpenSSH jobs and native-system validation matrix still apply. + +*Last modified: 2026-10-23 18:39:31* diff --git a/VERSIONING.md b/VERSIONING.md index a784c01..a761003 100644 --- a/VERSIONING.md +++ b/VERSIONING.md @@ -1,17 +1,29 @@ -# Version policy and 6.0 migration +# Version policy and major differences SSHScript aims to let engineers and AI reliably execute, understand, and take over automation. A version identifies a public execution contract, not a claim that a command or deployment is safe or successful. +## Current development policy + +SSHScript is still in development. Unless the maintainer explicitly requests +backward compatibility, changes do not need to preserve older APIs, CLI behavior, +configuration or data formats. Do not add compatibility layers, legacy aliases, +or fallback/migration tools solely for old versions. Provide only brief notes on +major differences and any necessary caller changes. Historical version and +migration notes below record earlier contracts; they do not impose compatibility +requirements on current development. Version selection and publication still +require their existing authorization. + ## Current contracts | Version | Contract and status | | --- | --- | | 3.1.5 | Published baseline described in API_GUIDE.md; one-shot Session commands return CommandResult, but persistent console calls return two live buffers. | | 4.0.2 | Previous production contract: completed console results, settings, managed jobs and authenticated-console handshakes; incompatible with parts of 3.1.5. | -| 5.0.0 | Latest published contract: calling-host trust by default, explicit local/chain strategies and strict known_hosts file errors; preserves the 4.0 command execution contract. | -| 6.0.0 | Current source contract: interactive Python/dollar console, normal script completion with stop(), ScriptResult metadata, and removal of old exit/break APIs and control exceptions. Package publication is separate from source promotion. | +| 5.0.0 | Previous published contract: calling-host trust by default, explicit local/chain strategies and strict known_hosts file errors; preserves the 4.0 command execution contract. | +| 6.0.0 | Previous release contract: interactive Python/dollar console, normal script completion with stop(), ScriptResult metadata, and removal of old exit/break APIs and control exceptions. | +| 6.1.0 | Public Console(existing_session), host/agent updates, credential hooks, explicit ownership and cleanup/reuse status; unified CLI and corrected program Ctrl-C readiness. | Version 4.0.1 introduced the published 4.0 contract after release validation. Version 4.0.2 is a documentation and link maintenance patch with the same public @@ -23,7 +35,8 @@ syntax follows the same versioned execution contract. 6.0.0 is an incompatible major version, approved for the new execution contract. A source push and successful CI do not create a GitHub Release, tag or PyPI upload. -The latest published 5.0.0 package remains a separate artifact. +The 6.0.0 package was published separately; this historical section records its +script-completion changes. The current integration contract is 6.1.0. | Previous use | 6.0 replacement | | --- | --- | @@ -63,29 +76,43 @@ This default and the strict file-error behavior are incompatible with 4.0.2, so they are introduced in a new major version. Explicit `known_hosts="local"` preserves the previous source location, while still requiring a readable file. +## 6.1.0 public Console integration + +The public `Console` and CLI share one implementation. Compatibility with earlier +6.0 CLI behavior is intentionally not maintained: the internal InteractiveConsole +class, open()/execute() startup API, :help/:quit aliases, mode-specific error text +and silent failed-layer removal are gone. Use Console(session), submit(line), the +Python help/quit helpers and explicit cleanup/reuse status. Session SSH/SFTP and +command contracts keep their existing responsibilities. + +The maintainer selected 6.1.0 for this feature release under the current +development policy. It includes the above incompatible console changes, without +compatibility aliases. Read [the integration contract](EMBEDDED_CONSOLE.md). + ## Numbering and compatibility -Versions follow PEP 440 with major.minor.patch compatibility semantics. -Propose and discuss a major-version promotion with the maintainer before -changing the version; a request to publish does not itself choose a major bump: +Versions follow PEP 440. During active development, major/minor labels organize +feature releases and do not promise backward compatibility. The maintainer selects +and explicitly approves the release version; do not invent an automatic major bump +or preserve old behavior solely because the selected release is minor. + +- Major: a maintainer-selected major milestone or broad contract redesign. +- Minor: a feature release; may include incompatible changes during development. +- Patch: focused corrections or documentation updates; identify any behavior change. -- Major: incompatible public return types/unpacking, input data, exceptions, - lifecycle/cleanup guarantees, authentication or accepted command templates. -- Minor: additive public features that preserve existing execution contracts. -- Patch: compatible fixes. A security fix may require an incompatible restriction; - release notes must identify that exception, its impact and migration. - Development: NEXT.devN; increment N when distributing a changed development contract. Do not reuse an artifact version for different distributed inputs. - Candidates: NEXTaN, NEXTbN or NEXTrcN identify prerelease artifacts. This project's current production pipeline accepts only stable versions; prereleases use local artifacts/TestPyPI until a separate public prerelease channel is authorized. -Old forms may remain temporarily for migration, but new examples use the -canonical API. Deprecations require an explicit warning and replacement guidance; -removal normally waits for a major release. If security or data correctness -requires an earlier restriction, document it explicitly. Compatibility covers -both Python and .spy entry points. Do not reinterpret a timeout or transport -failure as command success, or channel closure as confirmed remote termination. +During the current development phase, older forms may be removed without a +compatibility layer, deprecation period or migration implementation unless the +maintainer explicitly requests those. Briefly note major differences for both +Python and .spy callers. The numbering rules describe release labels, not a +requirement to maintain old behavior during development. Do not reinterpret a +timeout or transport failure as command success, or channel closure as confirmed +remote termination. Only _version.py owns the package version. Edit it in the designated working_branch directory on branch working_branch. Imports, CLI, wheel/sdist @@ -156,4 +183,4 @@ Consult that artifact's version-labelled API guide before generating examples. Production gates reject prerelease/local versions before uploading; they never silently relabel development builds as stable. -*Last modified: 2026-10-22 23:25:30* +*Last modified: 2026-10-23 18:39:31* diff --git a/examples/embedded_agent.py b/examples/embedded_agent.py new file mode 100644 index 0000000..637b438 --- /dev/null +++ b/examples/embedded_agent.py @@ -0,0 +1,36 @@ +"""Credential-free local host/agent integration: PYTHONPATH=. python examples/embedded_agent.py.""" +import time +from sshscript import Console, Session + + +def drive(session): + # In a real host, resolve credentials from its own config/secret store and + # enforce its policy here. No automatic user candidate selection is provided. + def resolve(request): + raise PermissionError('this example does not authorize account switching') + + with Console(session, credential_resolver=resolve) as console: + assert console.submit('saved = 42').state == 'completed' + console.submit('$export EMBEDDED_EXAMPLE=retained; cd /tmp') + update = console.start_command('printf "%s\\n" "$EMBEDDED_EXAMPLE"; pwd', timeout=3) + while True: + print(update.stdout, end='') + if update.state != 'running': + break + time.sleep(.03) + update = console.poll() + if update.state != 'completed': + raise RuntimeError('example command failed: ' + str(update.error_type)) + assert update.result.exitcode == 0 + assert console.submit('assert saved == 42').state == 'completed' + print(console.environments) + assert console.session_reuse == 'confirmed' + print(session.exec_command('printf "original Session reused\\n"', check=True).stdout, end='') + + +if __name__ == '__main__': + session = Session() + try: + drive(session) + finally: + session.close(strict=True) diff --git a/examples/embedded_terminal.py b/examples/embedded_terminal.py new file mode 100644 index 0000000..ea8c5fe --- /dev/null +++ b/examples/embedded_terminal.py @@ -0,0 +1,21 @@ +"""Run locally: PYTHONPATH=. python examples/embedded_terminal.py + +OpsFeast can call handover(target_session) after its own authorized connection. +This example makes no SSH connection and never runs su/sudo automatically. +""" +from sshscript import Console, Session + + +def handover(session): + with Console(session, terminal=True) as console: + console.interact() + assert console.session_reuse == 'confirmed' + return session.exec_command('printf "Session remains usable\\n"', check=True) + + +if __name__ == '__main__': + session = Session() + try: + print(handover(session).stdout, end='') + finally: + session.close(strict=True) diff --git a/pyproject.toml b/pyproject.toml index 70a3766..2d63ea5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ authors = [ {name = "Yeh, Hsin-Yuan", email = "iapyeh@gmail.com"}, ] classifiers = [ - "Development Status :: 5 - Production/Stable", + "Development Status :: 4 - Beta", "Operating System :: MacOS", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3", diff --git a/skills/sshscript/SKILL.md b/skills/sshscript/SKILL.md index 51dd31d..555e63b 100644 --- a/skills/sshscript/SKILL.md +++ b/skills/sshscript/SKILL.md @@ -5,14 +5,32 @@ description: Write, adapt, and verify Python or .spy automation using SSHScript # Use SSHScript -This skill describes the **SSHScript 6.0.0 contract, Python 3.11+**. Read +SSHScript is currently in development. Unless the user explicitly requests +backward compatibility, use the current design without retaining or adding legacy +interfaces, compatibility layers, or fallback/migration paths solely for older +versions. Briefly identify the main differences and any required caller changes; +do not expand the task into compatibility work. This policy is specific to SSHScript. + +This skill describes the **SSHScript 6.1.0 contract, Python 3.11+**. Read [the agent guide](references/agent-guide.md) before generating automation. Select only the API sections needed for the user's task. +For an existing host-owned Session, use the new public `Console(session)` API +with a context manager, default borrowed ownership, structured submit/poll updates +and explicit Session reuse reporting. Read the guide section +[Existing Session integration](references/agent-guide.md#existing-session-integration-unified-console) +and the matching EMBEDDED_CONSOLE.md (bundled references/embedded-console.md). +CLI and embedding callers share one Console core. Earlier CLI compatibility is +not maintained. Require 6.1.0 or a verified equivalent source revision for this API. +Do not reconnect, edit reserved variables, log credentials or treat silence as success. +After program interrupt(), follow the returned state: waiting_input means its +configured prompt matched; running means keep polling. KeyboardInterrupt alone +does not establish readiness or program failure. + For retained, step-by-step interaction, read -[Interactive console (6.0)](references/agent-guide.md#interactive-console-60). -Use a matching 6.0.0 checkout or verified wheel; source publication alone does -not upload a package. Published 5.0.0 lacks the console and retains older APIs. +[Interactive console (6.1)](references/agent-guide.md#interactive-console-61). +Install `sshscript==6.1.0` or use a matching verified artifact. Earlier 6.0 +console internals do not supply the supported host integration contract. In the console, `exit`/`exit()` leave one layer and `quit`/`quit()` close all layers. `$.stop()` completes a script; `$.exit()` is removed in both modes. Use the matching checkout's `CONSOLE.md` for the full contract. @@ -86,4 +104,4 @@ official links in the agent guide and confirm their contract version. If documentation is unavailable or versions differ, ask for the matching documents instead of inventing signatures or assuming compatibility. -*Last modified: 2026-10-22 23:24:55* +*Last modified: 2026-10-23 18:39:31* diff --git a/skills/sshscript/references/agent-guide.md b/skills/sshscript/references/agent-guide.md index 368048d..8b1eb25 100644 --- a/skills/sshscript/references/agent-guide.md +++ b/skills/sshscript/references/agent-guide.md @@ -1,6 +1,11 @@ # SSHScript Agent Guide -**Documentation contract: SSHScript 6.0.0; Python 3.11+.** +**Documentation contract: SSHScript 6.1.0; Python 3.11+.** + +**Development policy:** SSHScript is still in development and does not maintain +backward compatibility unless the user explicitly requests it. Follow the current +design and give brief notes on major differences; do not add compatibility layers +or migration paths solely for older versions by default. SSHScript runs commands locally and over SSH with a Python `Session` API. Optional `.spy` dollar syntax exposes the same execution model. Use it when @@ -45,11 +50,11 @@ For a new environment, when installation is authorized: ```sh python3 -m venv .venv . .venv/bin/activate -python3 -m pip install /path/to/sshscript-6.0.0-py3-none-any.whl +python3 -m pip install "sshscript==6.1.0" ``` -Use a verified 6.0.0 wheel until publication; after publication, pin -`sshscript==6.0.0`. A source push does not publish a package. +Pin `sshscript==6.1.0` for this contract and verify the imported version. +A source checkout or a same-version development wheel requires its exact commit/hash. Pin transitive dependencies in the application's lock file for repeatable deployment. Python 3.11+ is required. Consult the project's test reports for @@ -112,14 +117,33 @@ remote argv is quoted for a POSIX login shell. Shell operators inside argv are literal data. Console commands accept strings; quote dynamic arguments with `shlex.join()`. Do not interpolate untrusted values into shell syntax. -## Interactive console (6.0) - -This section requires SSHScript 6.0.0. Published 5.0.0 lacks this console and -retains older script-control APIs. Record the source commit or verified wheel -identity before using the 6.0 contract. +## Existing Session integration (unified Console) + +The new `from sshscript import Console` surface borrows an existing connected +Session and never reconnects. Use its context manager and structured submit/poll +updates; require `session_reuse == "confirmed"` after close before reuse. +`start_command` is asynchronous shell execution; submit executes Python/dollar +cells synchronously. Generic jobs cannot identify stdin readiness. Use explicit +enter() protocols for text programs; silence is not completion. Program Ctrl-C +returns waiting_input only after its configured prompt matches; otherwise it stays +running and poll may observe a later prompt. Readiness persists across polls. +KeyboardInterrupt records the attempt and does not establish program failure. The host Python +process needs no terminal, but the shell uses PTY by default for interruption and +authentication. Missing non-terminal credentials fail; return SwitchCredentials +from the host resolver and never embed password literals in input source. +Hooks cover managed switches, not arbitrary trusted Python/lexical scripts. +Read the checkout EMBEDDED_CONSOLE.md or bundled references/embedded-console.md +for ownership, failure and secret-handling limits. CLI and host calls share this +contract; older CLI entry points/aliases are removed. Use a matching verified +6.1.0 implementation; earlier 6.0 console internals are unsupported. + +## Interactive console (6.1) + +This section describes SSHScript 6.1.0. Record the installed version, source +commit or verified wheel identity before using the Console integration contract. Start `python3 -B sshscript.py console` from the development checkout, or -`sshscript console` from an installed 6.0.0 wheel. Keep the same terminal/process +`sshscript console` from an installed 6.1.0 wheel. Keep the same terminal/process alive between inputs; restarting it loses variables, connections and shells. A UI agent needs a tool that can send more input to that retained process. No temporary script file or Python/shell mode switch is required. @@ -429,4 +453,4 @@ for reporting issues. Recipe-specific schemas and vault updates belong to that recipe's own documentation. Reading this guide does not authorize executing examples or submitting a public report. -*Last modified: 2026-10-22 23:24:55* +*Last modified: 2026-10-23 18:39:31* diff --git a/src/sshscript/__init__.py b/src/sshscript/__init__.py index f45ce10..f7d4046 100755 --- a/src/sshscript/__init__.py +++ b/src/sshscript/__init__.py @@ -43,12 +43,14 @@ _require_python() if __package__: + from .interactive import Console, ConsoleUpdate, Environment, SwitchRequest, SwitchCredentials, SwitchOutcome, CredentialsRequiredError, ConsoleInitializationError, ConsoleCleanupError from ._version import __version__ from . import sshscript from . import session from . import errorutils from .spyimporter import spy_imports else: + from interactive import Console, ConsoleUpdate, Environment, SwitchRequest, SwitchCredentials, SwitchOutcome, CredentialsRequiredError, ConsoleInitializationError, ConsoleCleanupError from _version import __version__ import sshscript import session @@ -85,6 +87,7 @@ # return _global['____sshscript____'][0] __all__ = [ + 'Console', 'ConsoleUpdate', 'Environment', 'SwitchRequest', 'SwitchCredentials', 'SwitchOutcome', 'CredentialsRequiredError', 'ConsoleInitializationError', 'ConsoleCleanupError', 'run_file', 'check_file', 'run_script', diff --git a/src/sshscript/__init__.pyi b/src/sshscript/__init__.pyi index d4118bf..6011ed3 100644 --- a/src/sshscript/__init__.pyi +++ b/src/sshscript/__init__.pyi @@ -14,3 +14,5 @@ def run_script(script: str, varGlobals: dict[str, Any] | None = ..., showScript: def spy_imports() -> ContextManager[None]: ... def get_logger() -> Any: ... def set_logger(userlogger: Any = ..., logname: str | None = ...) -> Any: ... + +from .interactive import (Console as Console, ConsoleUpdate as ConsoleUpdate, Environment as Environment, SwitchRequest as SwitchRequest, SwitchCredentials as SwitchCredentials, SwitchOutcome as SwitchOutcome, CredentialsRequiredError as CredentialsRequiredError, ConsoleInitializationError as ConsoleInitializationError, ConsoleCleanupError as ConsoleCleanupError) diff --git a/src/sshscript/_version.py b/src/sshscript/_version.py index 351f840..ec89528 100644 --- a/src/sshscript/_version.py +++ b/src/sshscript/_version.py @@ -1,3 +1,3 @@ """Single source of the SSHScript package version.""" -__version__ = "6.0.0" +__version__ = "6.1.0" diff --git a/src/sshscript/interactive.py b/src/sshscript/interactive.py index e4dbe3f..d75bf83 100644 --- a/src/sshscript/interactive.py +++ b/src/sshscript/interactive.py @@ -6,7 +6,8 @@ from io import StringIO import tokenize from contextlib import ExitStack, redirect_stdout, redirect_stderr -from dataclasses import dataclass +from dataclasses import dataclass, field +import threading from getpass import getpass import sys import time @@ -15,9 +16,13 @@ if __package__: from . import dollarparser, tokenparser, patching from .session import Session + from .commandresult import CommandResult + from .commandjob import JobResult else: import dollarparser, tokenparser, patching from session import Session + from commandresult import CommandResult + from commandjob import JobResult class _LeaveLayer(BaseException): @@ -75,6 +80,7 @@ class _PythonConsole(code.InteractiveConsole): def __init__(self, namespace): super().__init__(namespace) self.cell = 1 + self.last_error = None def runsource(self, source, filename='', symbol='single'): filename = f'' @@ -130,24 +136,27 @@ def runsource(self, source, filename='', symbol='single'): return True except (SyntaxError, OverflowError, ValueError): self.cell += 1 + self.last_error = type(sys.exc_info()[1]).__name__ self.showsyntaxerror(filename) return False self.cell += 1 self.runcode(compiled) return False + def showsyntaxerror(self, filename=None): + self.write("SyntaxError: input compilation failed.\n") + def runcode(self, code_object): try: exec(code_object, self.locals) except (_LeaveLayer, _QuitConsole, SystemExit, KeyboardInterrupt): raise except BaseException as error: + self.last_error = type(error).__name__ self.locals['error'] = error - if isinstance(error, NameError): - self.write(f'{type(error).__name__}: {error}\n') - else: - # Do not echo credential-bearing source or arbitrary server text. - self.write(f'{type(error).__name__}: operation failed; inspect error for details.\n') + # Source and exception text may contain credentials. + self.write(f'{type(error).__name__}: operation failed; inspect error for details.\n') + @dataclass @@ -163,20 +172,96 @@ class _Layer: program_positions: tuple = (0, 0) -class InteractiveConsole: - """Own a stack of connection/console contexts and a persistent Python namespace. - All context entry and exit occurs on the calling thread. No input history is - persisted. Connections under sudo/su are rejected rather than implying that - the SSH Session has adopted the console's effective identity or key files. +class CredentialsRequiredError(ValueError): + """A managed identity switch needs an explicit credential decision.""" + + +class ConsoleInitializationError(RuntimeError): + """Initialization failed; the supplied Session needs reconciliation.""" + session_reuse = 'unknown' + + +class ConsoleCleanupError(RuntimeError): + """Cleanup or the post-cleanup Session probe failed; reuse is unknown.""" + session_reuse = 'unknown' + + +@dataclass(frozen=True) +class Environment: + kind: str + host: str + connection_user: str | None + effective_user: str + identity_verified: bool + + +@dataclass(frozen=True) +class SwitchRequest: + kind: str + username: str | None + environment: Environment + credential_supplied: bool + + +@dataclass(frozen=True) +class SwitchCredentials: + password: str | None = field(repr=False) + + +@dataclass(frozen=True) +class SwitchOutcome: + request: SwitchRequest + success: bool + environment: Environment + error_type: str | None = None + + +@dataclass(frozen=True) +class ConsoleUpdate: + state: str + stdout: str = '' + stderr: str = '' + result: CommandResult[int | None] | None = None + error_type: str | None = None + session_reuse: str = 'in_use' + + + +class Console: + """Persistent Python/dollar console shared by terminal and embedded callers. + + Borrow a supplied Session by default; explicitly transfer ownership with + ownership="owned". Use a context manager or always call close(). """ - def __init__(self, *, stdout=None, stderr=None, input_func=None, - password_func=None, session_factory=Session): - self._stdout = stdout or sys.stdout - self._stderr = stderr or sys.stderr + + def __init__(self, session, *, ownership='borrowed', terminal=False, + get_pty=True, credential_resolver=None, switch_observer=None, + stdout=None, stderr=None, input_func=None, password_func=None): + if ownership not in ('borrowed', 'owned'): + raise ValueError('ownership must be borrowed or owned') + if session.closed: + raise RuntimeError('Session is closed') + self._creator = threading.get_ident() + self._session = session + self._ownership = ownership + self._terminal = terminal + self._resolver = credential_resolver + self._observer = switch_observer + self._capture = (StringIO(), StringIO()) + self._job = None + self._job_positions = [0, 0] + self._state = 'ready' + self._error_type = None + self._reuse = 'in_use' + self._cleanup_error = None + self._uncertain = False + self._last_result = None + self._submitting = False + self._stdout = stdout if stdout is not None else (sys.stdout if terminal else self._capture[0]) + self._stderr = stderr if stderr is not None else (sys.stderr if terminal else self._capture[1]) self.input_func = input_func or input - self.password_func = password_func or getpass - self.session_factory = session_factory + self.password_func = password_func or (self._terminal_password if terminal else self._missing_password) self.layers = [] self.namespace = {'__name__': '__console__', 'getpass': self.password_func, 'control': self, '_console_control_': self, @@ -186,6 +271,22 @@ def __init__(self, *, stdout=None, stderr=None, input_func=None, self.python = _PythonConsole(self.namespace) self.more = False self.done = False + # Do not enter/exit the external Session or mutate its execution settings. + try: + with ExitStack() as resources: + wrapper = session.shell('bash', get_pty=get_pty) + wrapper.channel.dump2sys = (0, 0) + shell = resources.enter_context(wrapper) + self._push(resources, session, shell, 'ssh' if session.host else 'local', + session.host or 'local') + except BaseException: + self._reuse = 'unknown' + if ownership == 'owned': + try: + session.close(strict=True) + except BaseException: + pass + raise ConsoleInitializationError('console initialization failed; Session reuse is unknown') from None @property def current(self): @@ -203,61 +304,9 @@ def _push(self, resources, session, console, kind, host, *, program=False): self.layers.append(_Layer(resources.pop_all(), session, console, kind, host, user)) self._refresh() - def open(self, host=None, **options): - if self.layers or self.done: - raise RuntimeError('console has already been opened') - with ExitStack() as resources: - local = self.session_factory() - resources.callback(local.close, strict=True) - resources.enter_context(local) - local.set(verbose=False, verbose_stderr=False, check=False) - shell = resources.enter_context(local.shell('bash')) - self._push(resources, local, shell, 'local', 'local') - try: - if host is not None: - self.connect(host, **options) - except BaseException: - self.close() - raise - return self - - def connect(self, *args, **kwargs): - layer = self.current - if layer.kind not in ('local', 'ssh'): - raise RuntimeError('leave the current sudo/su/shell/program layer before connect(); SSH credentials belong to the connection Session') - with ExitStack() as resources: - remote = layer.session.connect(*args, **kwargs) - resources.callback(remote.close, strict=True) - resources.enter_context(remote) - remote.set(verbose=False, verbose_stderr=False, check=False) - shell = resources.enter_context(remote.shell('bash')) - self._push(resources, remote, shell, 'ssh', remote.host or '?') - - def _console_layer(self, kind, *args, **kwargs): - layer = self.current - if layer.kind == 'program': - raise RuntimeError('leave the interactive program before entering another layer') - with ExitStack() as resources: - console = resources.enter_context(getattr(layer.console, kind)(*args, **kwargs)) - self._push(resources, layer.session, console, - 'program' if kind == 'enter' else kind, layer.host, - program=kind == 'enter') - if kind == 'enter': - self._drain_program() - def shell(self, *args, **kwargs): self._console_layer('shell', *args, **kwargs) - def _identity_layer(self, kind, *args, **kwargs): - if 'password' not in kwargs: - # su's first positional argument is username; sudo's is password. - supplied_positionally = len(args) >= (2 if kind == 'su' else 1) - if not supplied_positionally: - if self.password_func is getpass and not sys.stdin.isatty(): - raise ValueError('provide password explicitly (password=None for passwordless authentication) when stdin is not a terminal') - kwargs['password'] = self.password_func(kind + ' password: ') - self._console_layer(kind, *args, **kwargs) - def sudo(self, *args, **kwargs): self._identity_layer('sudo', *args, **kwargs) @@ -322,7 +371,7 @@ def __getattr__(self, name): # Non-environment operations keep the regular API's session/console owner. if name in ('upload', 'download', 'pkey', 'set', 'get'): return getattr(self.current.session, name) - if name in ('send', 'input', 'expect', 'start'): + if name in ('send', 'input', 'expect'): return getattr(self.current.console, name) if name in ('stdout', 'stderr', 'exitcode'): if self.current.kind == 'program': @@ -331,28 +380,6 @@ def __getattr__(self, name): return getattr(result, name) if result is not None else (None if name == 'exitcode' else '') raise AttributeError(name) - def leave(self): - layer = self.layers.pop() - try: - layer.resources.close() - finally: - if self.layers: - self._refresh() - else: - self.done = True - self.namespace.update(session=None, console=None) - - def close(self): - failures = [] - while self.layers: - try: - self.leave() - except BaseException as error: - failures.append(error) - self.done = True - if failures: - raise BaseExceptionGroup('console cleanup failed', failures) - def _write(self, text, stream): if text: stream.write(text) @@ -366,7 +393,7 @@ def exec_command(self, command, *, check=None, timeout=None, raise TypeError('check must be bool') result = self._command(command, timeout=timeout, capture_limit=capture_limit, stop_timeout=stop_timeout) - if (enabled and result is not None and result.stop_reason == 'completed' + if (enabled and result is not None and (not isinstance(result, JobResult) or result.stop_reason == 'completed') and result.exitcode != 0): error = subprocess.CalledProcessError(result.exitcode, command, output=result.stdout, stderr=result.stderr) @@ -374,41 +401,6 @@ def exec_command(self, command, *, check=None, timeout=None, raise error return result - def _command(self, line, *, timeout=None, capture_limit=8 * 1024 * 1024, - stop_timeout=3): - if self.current.kind == 'program': - outcome = self.current.console.input(line) - self._drain_program() - if outcome == 'exited': - self.leave() - return - # Managed foreground jobs retain shell state, stream output and recover - # the parent shell after Ctrl-C. Completion is never inferred from silence. - job = self.current.console.start(line, timeout=timeout, capture_limit=capture_limit, - stop_timeout=stop_timeout, check=False) - positions = [0, 0] - def drain(): - for index, (text, stream) in enumerate(((job.stdout, self._stdout), - (job.stderr, self._stderr))): - self._write(text[positions[index]:], stream) - positions[index] = len(text) - try: - while not job.done: - drain() - time.sleep(0.03) - result = job.wait() - except KeyboardInterrupt: - result = job.stop() - self._write('\nKeyboardInterrupt\n', self._stderr) - finally: - drain() - self.namespace['result'] = self.current.result = result - if result.exitcode not in (0, None): - self._write(f'[exit {result.exitcode}]\n', self._stderr) - if result.stdout_truncated or result.stderr_truncated: - self._write('[output capture limit reached]\n', self._stderr) - return result - def _drain_program(self): layer = self.current channel = layer.console.channel @@ -442,14 +434,10 @@ def _program_control(self, character): self._drain_program() if outcome == 'exited': self.leave() - - def _interrupt(self): - self.python.resetbuffer() - self.more = False - if self.layers and self.current.kind == 'program': - self._program_control('\x03') else: - self._write('\nKeyboardInterrupt\n', self._stderr) + # _input_text consumes a matched prompt from its output buffers. + # Preserve the protocol result: poll cannot rediscover that prompt. + self._state = 'waiting_input' if outcome == 'prompt' else 'running' def _eof(self): if self.layers and self.current.kind == 'program': @@ -459,18 +447,66 @@ def _eof(self): self.more = False self.close() - def _discard_failed_layers(self): - while self.layers: - channel = self.current.console.channel - if not channel.closed and getattr(channel, '_state', None) not in ('failed', 'closed'): - break - self._write('Current shell is unavailable; leaving its environment.\n', self._stderr) + @property + def prompt(self): + if self.more: + return '... ' + layer = self.current + suffix = ' program' if layer.kind == 'program' else '' + return f'{layer.host} [{layer.user}]{suffix}> ' + + def interact(self): + if self.input_func is builtins.input and sys.stdin.isatty(): + # Python loads readline for its own REPL, but not when running a + # script. Enable terminal editing before our first input() call. try: - self.leave() - except Exception as error: - self._write('Cleanup failed: ' + type(error).__name__ + '\n', self._stderr) + import readline # noqa: F401 + except ImportError: + pass # Minimal Python builds retain input()'s basic editing. + self._write('SSHScript console. Type help for commands; quit to finish.\n', self._stdout) + try: + while not self.done: + try: + line = self.input_func(self.prompt) + except EOFError: + self.eof() + continue + except KeyboardInterrupt: + self.interrupt() + continue + try: + self.submit(line) + while self._job is not None: + update = self.poll() + if update.state == 'failed': + diagnostic = (f'exit {update.result.exitcode}' if update.result is not None + and update.result.exitcode is not None + else update.error_type or 'operation failed') + self._write(f'[{diagnostic}]\n', self._stderr) + if self._job is not None: + time.sleep(0.03) + except KeyboardInterrupt: + self.interrupt() + except Exception as error: + # Avoid echoing credential-bearing expressions or arbitrary + # server error text; the original exception is available as error. + self.namespace['error'] = error + self._write('Operation failed (' + type(error).__name__ + '). Use :status or inspect error.\n', self._stderr) + finally: + self.close() - def execute(self, line): + def _release_layer(self): + layer = self.layers.pop() + try: + layer.resources.close() + finally: + if self.layers: + self._refresh() + else: + self.done = True + self.namespace.update(session=None, console=None) + + def _submit_line(self, line): """Process one input line; variables and contexts survive later calls.""" if self.done: return @@ -482,17 +518,10 @@ def execute(self, line): self.help() elif helper == 'exit': self._exit_layer() - elif not self.more and line.strip() == ':quit': - self.close() - elif not self.more and line.strip() == ':help': - self._write(HELP + '\n', self._stdout) elif not self.more and line.strip() == ':status': for layer in self.layers: self._write(f'{layer.kind}: {layer.host} [{layer.user}]\n', self._stdout) self._write('Transfers and SSH credentials use the connection account.\n', self._stdout) - elif (not self.more and self.current.kind == 'program' - and not line.lstrip().startswith('$.')): - self._command(line) else: with redirect_stdout(self._stdout), redirect_stderr(self._stderr): self.more = self.python.push(line) @@ -507,50 +536,393 @@ def execute(self, line): self.more = False self.leave() except SystemExit as error: + self.python.last_error = type(error).__name__ self.python.resetbuffer() self.more = False - self._write(f'Python/script exited ({error.code}); console remains open.\n', self._stderr) + self._write('Python/script exited; console remains open.\n', self._stderr) finally: self._discard_failed_layers() + @staticmethod + def _terminal_password(prompt='Password: '): + if not sys.stdin.isatty(): + raise CredentialsRequiredError('terminal password input requires a terminal or host-provided credentials') + return getpass(prompt) + + @staticmethod + def _missing_password(*args, **kwargs): + raise CredentialsRequiredError('non-terminal console requires host-provided credentials') + + def _check(self, *, idle=False): + if threading.get_ident() != self._creator: + raise RuntimeError('Console operations require the constructing thread') + if idle and self._job is not None: + raise RuntimeError('poll or interrupt the foreground job before another operation') + @property - def prompt(self): - if self.more: - return '... ' + def connection(self): + """The current connection Session; its login identity is unchanged by su/sudo.""" + self._check() + return self.current.session + + @property + def environments(self): + self._check() + return tuple(Environment(layer.kind, layer.host, layer.session.username, + (self.layers[index - 1].user if layer.kind == 'program' + and index else layer.user), layer.kind != 'program') + for index, layer in enumerate(self.layers)) + + @property + def session_reuse(self): + return self._reuse + + def _identity_layer(self, kind, *args, **kwargs): + self._check(idle=True) + username = (args[0] if kind == 'su' and args else kwargs.get('username')) + if kind == 'sudo' and len(args) > 1: + username = args[1] + supplied = 'password' in kwargs or len(args) >= (2 if kind == 'su' else 1) + request = SwitchRequest(kind, username, self.environments[-1], supplied) + try: + credential = self._resolver(request) if self._resolver else None + if credential is not None: + if not isinstance(credential, SwitchCredentials): + raise TypeError('credential_resolver must return SwitchCredentials or None') + # Normalize positional password arguments before overriding them. + if len(args) >= (2 if kind == 'su' else 1): + args = list(args) + args[1 if kind == 'su' else 0] = credential.password + kwargs.pop('password', None) + else: + kwargs['password'] = credential.password + supplied = True + if not supplied and not self._terminal: + raise CredentialsRequiredError('provide credentials or explicit password=None') + if not supplied: + kwargs['password'] = self.password_func(kind + ' password: ') + self._console_layer(kind, *args, **kwargs) + except BaseException as error: + if self._observer: + self._observer(SwitchOutcome(request, False, self.environments[-1], + type(error).__name__)) + raise + if self._observer: + self._observer(SwitchOutcome(request, True, self.environments[-1])) + + def _console_layer(self, kind, *args, **kwargs): + self._check(idle=True) layer = self.current - suffix = ' program' if layer.kind == 'program' else '' - return f'{layer.host} [{layer.user}]{suffix}> ' + if layer.kind == 'program': + raise RuntimeError('leave the interactive program before entering another layer') + with ExitStack() as resources: + console = resources.enter_context(getattr(layer.console, kind)(*args, **kwargs)) + self._push(resources, layer.session, console, + 'program' if kind == 'enter' else kind, layer.host, + program=kind == 'enter') + if kind == 'enter': + self._drain_program() - def interact(self): - if self.input_func is builtins.input and sys.stdin.isatty(): - # Python loads readline for its own REPL, but not when running a - # script. Enable terminal editing before our first input() call. - try: - import readline # noqa: F401 - except ImportError: - pass # Minimal Python builds retain input()'s basic editing. - self._write('SSHScript console. Type help for commands; quit to finish.\n', self._stdout) + self._state = 'running' if kind == 'enter' else 'completed' + self._last_result, self._error_type = None, None + if kind == 'enter': + self._program_started = time.monotonic() + + def connect(self, *args, **kwargs): + self._check(idle=True) + layer = self.current + if layer.kind not in ('local', 'ssh'): + raise RuntimeError('leave the current sudo/su/shell/program layer before connect(); SSH credentials belong to the connection Session') + with ExitStack() as resources: + remote = layer.session.connect(*args, **kwargs) + resources.callback(remote.close, strict=True) + resources.enter_context(remote) + wrapper = remote.shell('bash') + wrapper.channel.dump2sys = (0, 0) + shell = resources.enter_context(wrapper) + self._push(resources, remote, shell, 'ssh', remote.host or '?') + + def _discard_failed_layers(self): + if any(layer.console.channel.closed or + getattr(layer.console.channel, '_state', None) in ('failed', 'closed') + for layer in self.layers): + self._uncertain = True + self._state = 'failed' + self._reuse = 'unknown' + + def _command(self, line, *, timeout=None, capture_limit=8 * 1024 * 1024, + stop_timeout=3): try: - while not self.done: - try: - line = self.input_func(self.prompt) - except EOFError: - self._eof() - continue - except KeyboardInterrupt: - self._interrupt() - continue + if self.current.kind == 'program': + return self.send_input(line).result + # Managed foreground jobs retain shell state, stream output and recover + # the parent shell after Ctrl-C. Completion is never inferred from silence. + job = self.current.console.start(line, timeout=timeout, capture_limit=capture_limit, + stop_timeout=stop_timeout, check=False) + positions = [0, 0] + def drain(): + for index, (text, stream) in enumerate(((job.stdout, self._stdout), + (job.stderr, self._stderr))): + self._write(text[positions[index]:], stream) + positions[index] = len(text) + try: + while not job.done: + drain() + time.sleep(0.03) + result = job.wait() + except KeyboardInterrupt: + result = job.stop() + self._write('\nKeyboardInterrupt\n', self._stderr) + finally: + drain() + self.namespace['result'] = self.current.result = result + if result.exitcode not in (0, None): + self._write(f'[exit {result.exitcode}]\n', self._stderr) + if result.stdout_truncated or result.stderr_truncated: + self._write('[output capture limit reached]\n', self._stderr) + except Exception as error: + self._last_result = getattr(error, 'result', None) + if self._last_result is not None and self._last_result.termination_status != 'confirmed': + self._uncertain, self._reuse = True, 'unknown' + raise + self._last_result = result + if result is not None and result.termination_status != 'confirmed': + self._uncertain, self._reuse = True, 'unknown' + if result is not None: + self._state = ('completed' if result.stop_reason == 'completed' + and result.exitcode == 0 else 'failed') + return result + + def _update(self, *, consume=True): + output = [] + for buffer in self._capture: + output.append(buffer.getvalue()) + if consume: + buffer.seek(0) + buffer.truncate(0) + return ConsoleUpdate(self._state, *output, self._last_result, + self._error_type, self._reuse) + + def submit(self, line): + """Evaluate one Python/dollar line, returning a structured update. + + Shell dollar commands block until completion; use start_command for polling. + Operation errors are reported by safe type, without source/error text. + """ + self._check(idle=True) + if self.done: + return self._update() + if self.current.kind == 'program' and not line.lstrip().startswith('$.'): + if self._bare_helper(line) is None: try: - self.execute(line) + return self.send_input(line) except KeyboardInterrupt: - self._interrupt() - except Exception as error: - # Avoid echoing credential-bearing expressions or arbitrary - # server error text; the original exception is available as error. - self.namespace['error'] = error - self._write('Operation failed (' + type(error).__name__ + '). Use :status or inspect error.\n', self._stderr) + return self.interrupt() + self._state, self._error_type, self._last_result = 'completed', None, None + self.python.last_error = None + self._submitting = True + try: + self._submit_line(line) + except ConsoleCleanupError: + raise + except (Exception, KeyboardInterrupt, SystemExit) as error: + self._state, self._error_type = 'failed', type(error).__name__ finally: + self._submitting = False + if self.python.last_error is not None: + self._state, self._error_type = 'failed', self.python.last_error + elif self.more: + self._state = 'incomplete' + if self.done: + self._state = 'closed' if self._reuse != 'unknown' else 'failed' + return self._update() + + def start(self, command, *, timeout=60, stop_timeout=3, + capture_limit=1024 * 1024, check=False): + """Start and track a foreground job, including prompt-level $.start().""" + self._check(idle=True) + if self.more: + raise RuntimeError('finish or interrupt incomplete Python input first') + self._job = self.current.console.start(command, timeout=timeout, + stop_timeout=stop_timeout, + capture_limit=capture_limit, check=check) + self._job_positions = [0, 0] + self._state, self._error_type, self._last_result = 'running', None, None + return self._job + + def start_command(self, command, *, timeout=60, stop_timeout=3, + capture_limit=1024 * 1024): + """Start a foreground shell command and return its first update.""" + self.start(command, timeout=timeout, stop_timeout=stop_timeout, + capture_limit=capture_limit, check=False) + return self.poll() + + def _drain_job(self): + for index, (text, stream) in enumerate(((self._job.stdout, self._stdout), + (self._job.stderr, self._stderr))): + self._write(text[self._job_positions[index]:], stream) + self._job_positions[index] = len(text) + + def _finish_job(self, *, stop=False): + job = self._job + try: + self._last_result = job.stop() if stop else job.wait() + self._state = ('completed' if self._last_result.stop_reason == 'completed' + and self._last_result.exitcode == 0 else 'failed') + except Exception as error: + self._last_result = getattr(error, 'result', None) + self._state, self._error_type = 'failed', type(error).__name__ + if self._last_result is None or self._last_result.termination_status != 'confirmed': + self._uncertain = True + self._reuse = 'unknown' + finally: + self._drain_job() + self._job = None + if self._last_result is not None: + self.namespace['result'] = self.current.result = self._last_result + if self._last_result.termination_status != 'confirmed': + self._uncertain, self._reuse = True, 'unknown' + + def poll(self): + """Return available output; never wait for a running command.""" + self._check() + if self._job is not None: + self._drain_job() + if self._job.done: + self._finish_job() + elif self.layers and self.current.kind == 'program': + channel = self.current.console.channel + texts = (str(channel._stdout), str(channel._stderr)) + parent = channel.prompts[-2] if len(channel.prompts) > 1 else None + prompt = channel.prompt + if isinstance(parent, str) and parent != prompt and any(parent in text for text in texts): + channel._mark_interactive_layer_exited(channel.executing_lock) + self._drain_program() + self.leave() + else: + if isinstance(prompt, str) and any(prompt in text for text in texts): + self._state = 'waiting_input' + self._drain_program() + self._discard_failed_layers() + return self._update() + + def send_input(self, text, *, newline=True, timeout=1): + """Send a raw write or one line to an enter() program.""" + self._check() + if self._job is not None: + raise RuntimeError('shell jobs do not accept input; use enter() for an interactive program') + if self.current.kind != 'program': + raise RuntimeError('input requires a running job or enter() program') + self._last_result, self._error_type = None, None + try: + if newline: + outcome = self.current.console.input(text, timeout=timeout) + else: + self.current.console.send(text) + outcome = None + self._drain_program() + if outcome == 'exited': + self.leave() + else: + self._state = 'waiting_input' if outcome == 'prompt' else 'running' + except TimeoutError: + self._state, self._error_type = 'running', 'TimeoutError' + self._discard_failed_layers() + return self._update() + + def _interrupt(self): + self.python.resetbuffer() + self.more = False + if self.layers and self.current.kind == 'program': + self._program_control('\x03') + else: + self._write('\nKeyboardInterrupt\n', self._stderr) + self._state = 'ready' + self._error_type = 'KeyboardInterrupt' + + def interrupt(self): + self._check() + if self._job is not None: + self._finish_job(stop=True) + else: + self._interrupt() + return self._update() + + def eof(self): + self._check(idle=True) + self._eof() + return self._update() + + def leave(self): + self._check(idle=True) + if len(self.layers) == 1: + return self.close() + layer = self.current + program = layer.kind == 'program' + output = (str(layer.console.channel._stdout), str(layer.console.channel._stderr)) if program else None + try: + self._release_layer() + if layer.console.channel._failure is not None: + raise RuntimeError('environment recovery failed') + if program: + status = self.current.console.exitcode + self._last_result = CommandResult(*output, status, layer.session.host, time.monotonic() - self._program_started, '') + self.namespace['result'] = self.current.result = self._last_result + self._state = 'completed' if status == 0 else 'failed' + except BaseException: + self._uncertain, self._reuse = True, 'unknown' + raise ConsoleCleanupError('layer cleanup failed; Session reuse is unknown') from None + + def close(self): + self._check() + if self.done: + if self._cleanup_error: + raise self._cleanup_error + return self._update() + failures = [] + if self._job is not None: + self._finish_job(stop=True) + while self.layers: + layer = self.current + if layer.console.channel._failure is not None: + self._uncertain = True + try: + self._release_layer() + if layer.console.channel._failure is not None: + self._uncertain = True + except BaseException as error: + failures.append(type(error).__name__) + self.done = True + try: + if self._ownership == 'owned': + self._session.close(strict=True) + self._reuse = 'closed' + elif not failures and not self._uncertain: + self._session.exec_command('true', check=True, command_timeout=3) + self._reuse = 'confirmed' + except BaseException as error: + failures.append(type(error).__name__) + if failures or self._uncertain: + self._state, self._reuse = 'failed', 'unknown' + self._cleanup_error = ConsoleCleanupError('console cleanup or Session recovery unconfirmed') + raise self._cleanup_error from None + self._state = 'closed' + return self._update(consume=not self._submitting) + + def __enter__(self): + self._check() + return self + + def __exit__(self, exc_type, exc_value, traceback): + try: self.close() + except BaseException as cleanup: + if exc_value is not None: + raise BaseExceptionGroup('host operation and console cleanup failed', + [exc_value, cleanup]) from None + raise + return False + HELP = '''Input is Python with SSHScript dollar syntax; variables persist between inputs. @@ -568,9 +940,8 @@ def interact(self): quit / quit() close all layers. $.exit() is not available. :status shows the environment stack. Ctrl-C cancels current input or execution. Ctrl-D at the Python prompt closes the console. In enter(), Ctrl-C/D go to the program. -:help and :quit remain aliases. Inside enter(), plain input goes to that program; $. expressions remain controls. -No input history is persisted. Full-screen programs are not supported in this preview.''' +No input history is persisted. Full-screen programs are not supported.''' @@ -591,16 +962,16 @@ def main(argv=None): if name not in ('host', 'ask_pass', 'known_hosts_path') and value is not None} if args.host is None and (options or args.ask_pass): parser.error('connection options require a host') - console = InteractiveConsole() + session = Session() try: - console.open() if args.known_hosts_path is not None: - console.current.session.set(known_hosts_path=args.known_hosts_path) - if args.ask_pass: - options['password'] = getpass('SSH password: ') - if args.host is not None: - console.connect(args.host, **options) - console.interact() + session.set(known_hosts_path=args.known_hosts_path) + with Console(session, ownership='owned', terminal=True) as console: + if args.ask_pass: + options['password'] = getpass('SSH password: ') + if args.host is not None: + console.connect(args.host, **options) + console.interact() finally: - console.close() + session.close(strict=True) return 0 diff --git a/src/sshscript/interactive.pyi b/src/sshscript/interactive.pyi new file mode 100644 index 0000000..68183f3 --- /dev/null +++ b/src/sshscript/interactive.pyi @@ -0,0 +1,71 @@ +from typing import Any, Callable, Literal, TextIO +from dataclasses import dataclass +from .session import Session +from .commandjob import JobResult, CommandJob +from .commandresult import CommandResult + +ConsoleState = Literal['ready', 'incomplete', 'running', 'waiting_input', 'completed', 'failed', 'closed'] +SessionReuse = Literal['in_use', 'confirmed', 'unknown', 'closed'] +class CredentialsRequiredError(ValueError): ... +class ConsoleInitializationError(RuntimeError): + session_reuse: str +class ConsoleCleanupError(RuntimeError): + session_reuse: str +@dataclass(frozen=True) +class Environment: + kind: str + host: str + connection_user: str | None + effective_user: str + identity_verified: bool +@dataclass(frozen=True) +class SwitchRequest: + kind: str + username: str | None + environment: Environment + credential_supplied: bool +@dataclass(frozen=True) +class SwitchCredentials: + password: str | None +@dataclass(frozen=True) +class SwitchOutcome: + request: SwitchRequest + success: bool + environment: Environment + error_type: str | None = ... +@dataclass(frozen=True) +class ConsoleUpdate: + state: ConsoleState + stdout: str = ... + stderr: str = ... + result: CommandResult[int | None] | None = ... + error_type: str | None = ... + session_reuse: SessionReuse = ... +class Console: + namespace: dict[str, Any] + more: bool + done: bool + def __init__(self, session: Session, *, ownership: Literal['borrowed', 'owned'] = ..., terminal: bool = ..., get_pty: bool = ..., credential_resolver: Callable[[SwitchRequest], SwitchCredentials | None] | None = ..., switch_observer: Callable[[SwitchOutcome], None] | None = ..., stdout: TextIO | None = ..., stderr: TextIO | None = ..., input_func: Callable[[str], str] | None = ..., password_func: Callable[[str], str] | None = ...) -> None: ... + @property + def connection(self) -> Session: ... + @property + def environments(self) -> tuple[Environment, ...]: ... + @property + def session_reuse(self) -> SessionReuse: ... + def submit(self, line: str) -> ConsoleUpdate: ... + def start(self, command: str, *, timeout: float | None = ..., stop_timeout: float = ..., capture_limit: int = ..., check: bool = ...) -> CommandJob: ... + def start_command(self, command: str, *, timeout: float | None = ..., stop_timeout: float = ..., capture_limit: int = ...) -> ConsoleUpdate: ... + def poll(self) -> ConsoleUpdate: ... + def send_input(self, text: str, *, newline: bool = ..., timeout: float = ...) -> ConsoleUpdate: ... + def interrupt(self) -> ConsoleUpdate: ... + def eof(self) -> ConsoleUpdate: ... + def leave(self) -> ConsoleUpdate | None: ... + def close(self) -> ConsoleUpdate: ... + def shell(self, *args: Any, **kwargs: Any) -> None: ... + def su(self, *args: Any, **kwargs: Any) -> None: ... + def sudo(self, *args: Any, **kwargs: Any) -> None: ... + def enter(self, *args: Any, **kwargs: Any) -> None: ... + def connect(self, *args: Any, **kwargs: Any) -> None: ... + def interact(self) -> None: ... + def __enter__(self) -> Console: ... + def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> Literal[False]: ... diff --git a/src/sshscript/sshscript.py b/src/sshscript/sshscript.py index 6dbe20b..c60aace 100755 --- a/src/sshscript/sshscript.py +++ b/src/sshscript/sshscript.py @@ -38,6 +38,7 @@ def warning_on_one_line(message, category, filename, lineno, file=None, line=None): return '%s:%s: %s: %s\n' % (filename, lineno, category.__name__, message) if __package__: + from .interactive import Console, ConsoleUpdate, Environment, SwitchRequest, SwitchCredentials, SwitchOutcome, CredentialsRequiredError, ConsoleInitializationError, ConsoleCleanupError from ._version import __version__ from .sessionsettings import execution_defaults from .session import ScriptResult, Session, CommandResult, CommandJob, CommandTimeoutError, JobResult @@ -49,6 +50,7 @@ def warning_on_one_line(message, category, filename, lineno, file=None, line=Non ## 2024/8/16, should add mydir into sys.path for python 3.12 mydir = os.path.abspath(os.path.dirname(__file__)) if not mydir in sys.path: sys.path.insert(0,mydir) + from interactive import Console, ConsoleUpdate, Environment, SwitchRequest, SwitchCredentials, SwitchOutcome, CredentialsRequiredError, ConsoleInitializationError, ConsoleCleanupError from _version import __version__ from sessionsettings import execution_defaults from session import ScriptResult, Session, CommandResult, CommandJob, CommandTimeoutError, JobResult @@ -285,7 +287,7 @@ def main(): import argparse parser = argparse.ArgumentParser(description='SSHScript: automation tools for Subprocess and SSH', - epilog='Interactive preview: sshscript console [host] (console --help for options)') + epilog='Interactive console: sshscript console [host] (console --help for options)') parser.add_argument('--script','-s', dest='showScript', action='store_true', diff --git a/src/sshscript/unittest/test_embedded_console.py b/src/sshscript/unittest/test_embedded_console.py new file mode 100644 index 0000000..38cbe10 --- /dev/null +++ b/src/sshscript/unittest/test_embedded_console.py @@ -0,0 +1,471 @@ +"""Local-process evidence and explicitly mocked identity/transport failures.""" +import contextlib +import io +import shlex +import sys +import subprocess +from pathlib import Path +import threading +import time +import unittest +from unittest.mock import patch + +from sshscript import (Console, Session, CredentialsRequiredError, + ConsoleCleanupError, ConsoleInitializationError, + SwitchCredentials) + + +class EmbeddedConsoleTests(unittest.TestCase): + def session(self): + session = Session() + self.addCleanup(session.close) + return session + + def console(self, session=None, **kwargs): + console = Console(session or self.session(), **kwargs) + self.addCleanup(console.close) + return console + + def complete(self, console, update): + outputs = [update.stdout] + deadline = time.monotonic() + 8 + while update.state == 'running': + self.assertLess(time.monotonic(), deadline) + time.sleep(.02) + update = console.poll() + outputs.append(update.stdout) + return update, ''.join(outputs) + + def test_borrow_without_auth_settings_mutation_and_reuse(self): + session = self.session() + session.set(verbose=True) + settings = session.get() + with patch.object(session, 'connect', side_effect=AssertionError('must not reconnect')): + console = self.console(session) + self.assertIs(console.connection, session) + self.assertEqual(session.get(), settings) + console.close() + self.assertEqual(console.session_reuse, 'confirmed') + self.assertFalse(session.closed) + self.assertEqual(session('printf alive').stdout, 'alive') + + def test_python_shell_state_and_result_failures(self): + console = self.console() + self.assertEqual(console.submit('values = []').state, 'completed') + self.assertEqual(console.submit('for n in range(2):').state, 'incomplete') + self.assertEqual(console.submit(' values.append(n)').state, 'incomplete') + self.assertEqual(console.submit('').state, 'completed') + self.assertEqual(console.namespace['values'], [0, 1]) + console.submit('$cd /tmp; export EMBEDDED_TEST=preserved') + update, output = self.complete(console, console.start_command('pwd; printf "$EMBEDDED_TEST"')) + self.assertEqual(update.state, 'completed') + self.assertIn('preserved', output) + self.assertIn('tmp', output) + update = console.submit('$false') + self.assertEqual((update.state, update.result.exitcode), ('failed', 1)) + self.assertEqual(console.submit('bad = )').state, 'failed') + self.assertEqual(console.submit('1/0').error_type, 'ZeroDivisionError') + self.assertEqual(console.submit('assert values == [0, 1]').state, 'completed') + + def test_no_credentials_in_repr_or_diagnostics(self): + console = self.console() + self.assertNotIn('secret-value', repr(SwitchCredentials('secret-value'))) + for source in ('raise ValueError("secret-value")', + 'raise SystemExit("secret-value")', + 'password="secret-value"; broken=)'): + update = console.submit(source) + self.assertEqual(update.state, 'failed') + self.assertNotIn('secret-value', update.stdout + update.stderr) + + def test_missing_credentials_is_structured_and_never_prompts(self): + console = self.console(password_func=lambda _: self.fail('hidden password prompt')) + update = console.submit('$.su("nobody")') + self.assertEqual(update.error_type, 'CredentialsRequiredError') + with self.assertRaises(CredentialsRequiredError): + console.sudo() + self.assertEqual(len(console.environments), 1) + default_console = self.console() + self.assertEqual(default_console.submit('getpass()').error_type, 'CredentialsRequiredError') + + def test_policy_and_credentials_hooks_with_simulated_identity(self): + outcomes, requests = [], [] + def resolve(request): + requests.append(request) + return SwitchCredentials('private-password') + console = self.console(credential_resolver=resolve, switch_observer=outcomes.append) + # Shell contexts simulate identity nesting only; this is not native su. + def simulated_switch(kind, *args, **kwargs): + self.assertEqual(kwargs['password'], 'private-password') + Console._console_layer(console, 'shell', 'bash') + console.current.kind, console.current.user = kind, 'simulated-root' + with patch.object(console, '_console_layer', side_effect=simulated_switch): + console.su('target', password='ignored') + self.assertEqual(console.environments[-1].effective_user, 'simulated-root') + self.assertEqual(console.environments[-1].kind, 'su') + self.assertTrue(requests[0].credential_supplied) + self.assertNotIn('private-password', repr(requests) + repr(outcomes)) + self.assertTrue(outcomes[0].success) + console.leave() + self.assertEqual(len(console.environments), 1) + + def test_policy_rejection_and_auth_failure_notify_without_secrets(self): + outcomes = [] + console = self.console(credential_resolver=lambda _: (_ for _ in ()).throw(PermissionError('secret')), + switch_observer=outcomes.append) + update = console.submit('$.sudo(password=None)') + self.assertEqual(update.error_type, 'PermissionError') + self.assertFalse(outcomes[-1].success) + self.assertNotIn('secret', repr(outcomes) + update.stderr) + console._resolver = lambda _: SwitchCredentials(None) + with patch.object(console, '_console_layer', side_effect=PermissionError('secret')): + update = console.submit('$.su("target")') + self.assertFalse(outcomes[-1].success) + self.assertEqual(update.state, 'failed') + + def test_owned_session_and_host_exception(self): + session = self.session() + with self.assertRaisesRegex(ValueError, 'host failure'): + with Console(session, ownership='owned'): + raise ValueError('host failure') + self.assertTrue(session.closed) + borrowed = self.session() + with self.assertRaises(ValueError): + with Console(borrowed): + raise ValueError('host failure') + self.assertFalse(borrowed.closed) + self.assertEqual(borrowed('printf alive').stdout, 'alive') + + def test_initialization_failure_preserves_borrowed_closes_owned(self): + for ownership in ('borrowed', 'owned'): + session = self.session() + # Failure after channel acquisition exercises the real context unwind. + with patch.object(Console, '_push', side_effect=RuntimeError('secret')): + with self.assertRaises(ConsoleInitializationError) as caught: + Console(session, ownership=ownership) + self.assertEqual(caught.exception.session_reuse, 'unknown') + self.assertNotIn('secret', str(caught.exception)) + self.assertEqual(session.closed, ownership == 'owned') + if ownership == 'borrowed': + self.assertEqual(session('printf alive').stdout, 'alive') + + def test_cleanup_failure_and_repeated_close_are_explicit(self): + session = self.session() + console = Console(session) + console.layers[-1].resources.callback(lambda: (_ for _ in ()).throw(RuntimeError('secret'))) + for _ in range(2): + with self.assertRaises(ConsoleCleanupError) as caught: + console.close() + self.assertNotIn('secret', str(caught.exception)) + self.assertEqual(console.session_reuse, 'unknown') + self.assertFalse(session.closed) + + def test_failed_health_probe_reports_unknown(self): + session = self.session() + console = Console(session) + with patch.object(session, 'exec_command', side_effect=ConnectionError('private server text')): + with self.assertRaises(ConsoleCleanupError): + console.close() + self.assertEqual(console.session_reuse, 'unknown') + + def test_poll_interrupt_timeout_and_exclusivity(self): + console = self.console() + update = console.start_command('sleep 30', timeout=5, stop_timeout=1) + self.assertEqual(update.state, 'running') + with self.assertRaises(RuntimeError): + console.submit('value=1') + update = console.interrupt() + self.assertEqual(update.state, 'failed') + self.assertEqual(update.result.stop_reason, 'cancelled') + self.assertEqual(update.result.termination_status, 'confirmed') + update, _ = self.complete(console, console.start_command('sleep 30', timeout=.15, stop_timeout=1)) + self.assertEqual((update.state, update.error_type), ('failed', 'CommandTimeoutError')) + self.assertEqual(console.submit('$printf recovered').stdout, 'recovered') + + def test_input_program_state_and_layers(self): + console = self.console() + command = shlex.quote(sys.executable) + ' -q -u' + console.enter(command, prompt='>>>', exit='quit()') + self.assertFalse(console.environments[-1].identity_verified) + update = console.send_input('print("program-value")', timeout=2) + self.assertEqual(update.state, 'waiting_input') + self.assertIn('program-value', update.stdout) + update = console.send_input('raise SystemExit', timeout=2) + self.assertEqual(update.state, 'completed') + self.assertEqual(len(console.environments), 1) + + def test_program_idle_interrupt_retains_readiness_and_cleanup(self): + # Real Python and PTY; no host TTY or simulated prompt outcomes. + for ending in ('eof', 'leave', 'close'): + with self.subTest(ending=ending): + session = self.session() + console = self.console(session) + console.enter(shlex.quote(sys.executable) + ' -q -u', + prompt='>>>', exit='quit()') + self.assertEqual(console.poll().state, 'waiting_input') + update = console.interrupt() + self.assertEqual(update.state, 'waiting_input') + self.assertEqual(update.error_type, 'KeyboardInterrupt') + # The protocol consumed the prompt; polls must retain readiness + # without sending extra input to provoke another prompt. + for _ in range(3): + self.assertEqual(console.poll().state, 'waiting_input') + update = console.send_input('print(6 * 7)', timeout=2) + self.assertEqual(update.state, 'waiting_input') + self.assertIn('42', update.stdout) + getattr(console, ending)() + if ending != 'close': + self.assertEqual(len(console.environments), 1) + self.assertFalse(console.done) + self.assertEqual(console.close().session_reuse, 'confirmed') + self.assertFalse(session.closed) + self.assertEqual(session('printf owner-alive').stdout, 'owner-alive') + + def test_running_program_interrupt_recovers_prompt(self): + console = self.console() + console.enter(shlex.quote(sys.executable) + ' -q -u', + prompt='>>>', exit='quit()') + update = console.send_input('import time; print("sleep-started", flush=True); time.sleep(30)', + timeout=.3) + self.assertEqual(update.state, 'running') + self.assertIn('\r\nsleep-started\r\n', update.stdout + console.poll().stdout) + update = console.interrupt() + self.assertEqual(update.state, 'waiting_input') + self.assertEqual(console.poll().state, 'waiting_input') + self.assertEqual(console.send_input('print("recovered")').state, 'waiting_input') + console.eof() + self.assertEqual(console.close().session_reuse, 'confirmed') + + def test_program_ignoring_interrupt_stays_running_until_real_prompt(self): + console = self.console() + console.enter(shlex.quote(sys.executable) + ' -q -u', + prompt='>>>', exit='quit()') + console.send_input('import signal, time; signal.signal(signal.SIGINT, signal.SIG_IGN)') + update = console.send_input('print("ignoring-started", flush=True); time.sleep(4)', timeout=.3) + self.assertEqual(update.state, 'running') + self.assertIn('\r\nignoring-started\r\n', update.stdout + console.poll().stdout) + update = console.interrupt() + self.assertEqual(update.state, 'running') + self.assertEqual(console.poll().state, 'running') + update, _ = self.complete(console, update) + self.assertEqual(update.state, 'waiting_input') + self.assertEqual(console.send_input('print("still-alive")').state, 'waiting_input') + console.leave() + self.assertEqual(console.close().session_reuse, 'confirmed') + + def test_program_interrupt_delayed_prompt_recovers_via_poll(self): + console = self.console() + console.enter(shlex.quote(sys.executable) + ' -q -u', + prompt='>>>', exit='quit()') + source = 'import time\ntry:\n print("delay-started", flush=True); time.sleep(30)\nexcept KeyboardInterrupt:\n time.sleep(3)' + update = console.send_input('exec(' + repr(source) + ')', timeout=.3) + self.assertEqual(update.state, 'running') + self.assertIn('\r\ndelay-started\r\n', update.stdout + console.poll().stdout) + update = console.interrupt() + self.assertEqual(update.state, 'running') + update, _ = self.complete(console, update) + self.assertEqual(update.state, 'waiting_input') + self.assertEqual(console.poll().state, 'waiting_input') + console.eof() + self.assertEqual(console.close().session_reuse, 'confirmed') + + def test_program_exit_on_interrupt_preserves_real_status(self): + session = self.session() + console = self.console(session) + console.enter(shlex.quote(sys.executable) + ' -q -u', + prompt='>>>', exit='quit()') + console.send_input('import signal; signal.signal(signal.SIGINT, signal.SIG_DFL)') + update = console.interrupt() + self.assertEqual(update.state, 'failed') + self.assertEqual(update.result.exitcode, 130) + self.assertEqual(len(console.environments), 1) + self.assertEqual(console.close().session_reuse, 'confirmed') + self.assertEqual(session('printf restored').stdout, 'restored') + + def test_quit_eof_leave_and_keyboard_interrupt(self): + for action in ('quit', 'eof', 'leave'): + session = self.session() + console = Console(session) + console.shell('bash') + console.leave() + if action == 'quit': + update = console.submit('quit') + self.assertEqual(update.state, 'closed') + else: + getattr(console, action)() + self.assertEqual(console.session_reuse, 'confirmed') + self.assertFalse(session.closed) + console = self.console() + console.submit('for n in range(2):') + self.assertEqual(console.interrupt().state, 'ready') + self.assertEqual(console.submit('x=42').state, 'completed') + + + def test_program_failure_has_real_exit_status(self): + console = self.console() + console.enter(shlex.quote(sys.executable) + ' -q -u', prompt='>>>', exit='quit()') + update = console.send_input('raise SystemExit(7)', timeout=2) + self.assertEqual(update.state, 'failed') + self.assertEqual(update.result.exitcode, 7) + + def test_program_timeout_then_poll_recovers_completion(self): + console = self.console() + console.enter(shlex.quote(sys.executable) + ' -q -u', prompt='>>>', exit='quit()') + update = console.send_input('import time; time.sleep(1); raise SystemExit(9)', timeout=.4) + self.assertEqual(update.state, 'running') + update, _ = self.complete(console, update) + self.assertEqual(update.state, 'failed') + self.assertEqual(update.result.exitcode, 9) + + def test_observer_failure_leaves_successful_switch_owned_for_cleanup(self): + console = self.console(credential_resolver=lambda _: SwitchCredentials(None), + switch_observer=lambda _: (_ for _ in ()).throw(RuntimeError('secret'))) + def simulated_switch(kind, *args, **kwargs): + Console._console_layer(console, 'shell', 'bash') + console.current.kind = kind + with patch.object(console, '_console_layer', side_effect=simulated_switch): + update = console.submit('$.sudo()') + self.assertEqual(update.error_type, 'RuntimeError') + self.assertEqual(len(console.environments), 2) + self.assertNotIn('secret', update.stderr) + + def test_mock_target_retains_jump_and_login_identity(self): + # Local channels simulate a connected target; no SSH login occurs here. + parent, target = self.session(), self.session() + target.host, target.username = 'simulated-target', 'login-user' + with patch.object(parent, 'close', wraps=parent.close) as parent_close: + with Console(target) as console: + environment = console.environments[-1] + self.assertEqual(environment.connection_user, 'login-user') + self.assertEqual(environment.host, 'simulated-target') + self.assertNotEqual(environment.effective_user, 'login-user') + parent_close.assert_not_called() + self.assertFalse(target.closed) + + def test_job_input_is_rejected_and_program_eof(self): + console = self.console() + console.start_command('sleep 30', timeout=3) + with self.assertRaisesRegex(RuntimeError, 'use enter'): + console.send_input('answer') + console.interrupt() + console.enter(shlex.quote(sys.executable) + ' -q -u', prompt='>>>', exit='quit()') + update = console.send_input('print("raw-reply")\n', newline=False) + update, output = self.complete(console, update) + self.assertEqual(update.state, 'waiting_input') + self.assertIn('raw-reply', output) + console.eof() + self.assertEqual(len(console.environments), 1) + self.assertFalse(console.done) + + def test_unknown_job_recovery_and_host_exception_are_both_visible(self): + console = Console(self.session()) + console.start_command('sleep 30', timeout=5, stop_timeout=1) + with patch.object(console._job, 'stop', side_effect=RuntimeError('private')): + update = console.interrupt() + self.assertEqual(update.session_reuse, 'unknown') + with self.assertRaises(BaseExceptionGroup) as caught: + with console: + raise ValueError('host failure') + self.assertEqual([type(error) for error in caught.exception.exceptions], + [ValueError, ConsoleCleanupError]) + + def test_dollar_start_is_tracked_and_cleanup_catches_silent_channel_failure(self): + console = self.console() + update = console.submit('job = $.start("sleep 30", timeout=3)') + self.assertEqual(update.state, 'running') + self.assertEqual(console.interrupt().result.stop_reason, 'cancelled') + session = self.session() + broken = Console(session) + broken.current.console.channel.fail(ConnectionError('secret')) + with self.assertRaises(ConsoleCleanupError): + broken.close() + self.assertEqual(broken.session_reuse, 'unknown') + self.assertFalse(session.closed) + + def test_quit_preserves_final_output_once_and_input_clears_stale_status(self): + console = self.console() + update = console.submit('print("final-output"); quit()') + self.assertEqual(update.state, 'closed') + self.assertIn('final-output', update.stdout) + self.assertEqual(console.poll().stdout, '') + console = self.console() + console.enter(shlex.quote(sys.executable) + ' -q -u', prompt='>>>', exit='quit()') + update = console.send_input('import time; time.sleep(1)', timeout=.4) + self.assertEqual(update.error_type, 'TimeoutError') + update, _ = self.complete(console, update) + self.assertEqual(update.state, 'waiting_input') + update = console.send_input('print("next-input")', timeout=2) + self.assertIsNone(update.error_type) + self.assertIsNone(update.result) + console.eof() + + def test_single_public_core_and_removed_legacy_entry_points(self): + import interactive + self.assertIs(interactive.Console, Console) + self.assertFalse(hasattr(interactive, 'InteractiveConsole')) + self.assertFalse(hasattr(Console, 'open')) + self.assertFalse(hasattr(Console, 'execute')) + console = self.console() + for alias in (':help', ':quit'): + self.assertEqual(console.submit(alias).state, 'failed') + self.assertFalse(console.done) + + def test_terminal_and_host_use_identical_safe_diagnostics(self): + diagnostics = [] + for terminal in (False, True): + output, errors = io.StringIO(), io.StringIO() + with Console(self.session(), terminal=terminal, stdout=output, stderr=errors) as console: + for source in ('unknown_python_name', 'password="private"; broken=)', + 'raise SystemExit("private")'): + self.assertEqual(console.submit(source).state, 'failed') + self.assertNotIn('private', errors.getvalue()) + diagnostics.append(errors.getvalue()) + self.assertEqual(*diagnostics) + + def test_terminal_mode_without_tty_never_prompts_for_missing_credentials(self): + console = self.console(terminal=True, stdout=io.StringIO(), stderr=io.StringIO()) + with patch('interactive.sys.stdin.isatty', return_value=False), \ + patch('interactive.getpass', side_effect=AssertionError('hidden password prompt')): + for source in ('$.sudo()', 'getpass()'): + self.assertEqual(console.submit(source).error_type, 'CredentialsRequiredError') + + def test_cli_constructs_public_core_with_owned_session(self): + import interactive + with patch('interactive.Console', wraps=Console) as factory, \ + patch('builtins.input', return_value='quit'), \ + contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(interactive.main([]), 0) + factory.assert_called_once() + self.assertEqual(factory.call_args.kwargs, {'ownership': 'owned', 'terminal': True}) + self.assertTrue(factory.call_args.args[0].closed) + + def test_cli_drains_public_foreground_job_and_reports_failure(self): + root = Path(__file__).resolve().parents[1] + cli = subprocess.run([sys.executable, '-B', str(root / 'sshscript.py'), 'console'], + input='$.start("printf streamed; false", timeout=3)\nquit\n', + text=True, capture_output=True, timeout=20) + self.assertEqual(cli.returncode, 0, cli.stderr) + self.assertIn('streamed', cli.stdout) + self.assertIn('[exit 1]', cli.stderr) + + def test_thread_affinity(self): + console = self.console() + errors = [] + def other(): + try: + console.close() + except RuntimeError as error: + errors.append(error) + thread = threading.Thread(target=other) + thread.start() + thread.join(2) + self.assertEqual(len(errors), 1) + self.assertFalse(console.done) + + def test_terminal_handover_uses_existing_session(self): + session = self.session() + lines = iter(['answer=42', '$printf terminal-value', 'quit']) + output = io.StringIO() + with Console(session, terminal=True, input_func=lambda _: next(lines), stdout=output) as console: + console.interact() + self.assertIn('terminal-value', output.getvalue()) + self.assertEqual(console.session_reuse, 'confirmed') diff --git a/src/sshscript/unittest/test_interactive_console.py b/src/sshscript/unittest/test_interactive_console.py index 2a52e2b..824e988 100644 --- a/src/sshscript/unittest/test_interactive_console.py +++ b/src/sshscript/unittest/test_interactive_console.py @@ -12,7 +12,7 @@ import sshscript import patching -from interactive import InteractiveConsole +from interactive import Console from scriptcontrol import ScriptResult from session import Session @@ -124,7 +124,7 @@ def setUp(self): def open_console(self): output, errors = io.StringIO(), io.StringIO() - console = InteractiveConsole(stdout=output, stderr=errors).open() + console = Console(Session(), ownership='owned', terminal=True, stdout=output, stderr=errors) self.addCleanup(console.close) return console, output, errors @@ -132,61 +132,61 @@ def test_directory_environment_variables_python_and_failure_survive_inputs(self) console, output, errors = self.open_console() with tempfile.TemporaryDirectory() as folder: import shlex - console.execute('$cd ' + shlex.quote(folder)) - console.execute('$export CONSOLE_FIXTURE=retained') - console.execute('$pwd; printf "%s" "$CONSOLE_FIXTURE"') + console.submit('$cd ' + shlex.quote(folder)) + console.submit('$export CONSOLE_FIXTURE=retained') + console.submit('$pwd; printf "%s" "$CONSOLE_FIXTURE"') self.assertIn(folder, output.getvalue()) self.assertIn('retained', output.getvalue()) - console.execute('$false') + console.submit('$false') self.assertEqual(console.namespace['result'].exitcode, 1) self.assertIn('[exit 1]', errors.getvalue()) - console.execute('options = {"port": 2222}') - console.execute('for index in range(2):') - console.execute(' options["count"] = index') - console.execute('') - console.execute('$printf after-failure') + console.submit('options = {"port": 2222}') + console.submit('for index in range(2):') + console.submit(' options["count"] = index') + console.submit('') + console.submit('$printf after-failure') self.assertIn('after-failure', output.getvalue()) self.assertEqual(console.namespace['options'], {'port': 2222, 'count': 1}) - console.execute('print(options["port"])') + console.submit('print(options["port"])') self.assertIn('2222', output.getvalue()) def test_bare_name_is_python_and_never_an_implicit_shell_command(self): console, output, errors = self.open_console() with patch.object(console, 'exec_command') as execute: - console.execute('hostname') + console.submit('hostname') self.assertIn('NameError', errors.getvalue()) - console.execute('hostname = "a Python variable"') - console.execute('hostname') + console.submit('hostname = "a Python variable"') + console.submit('hostname') execute.assert_not_called() self.assertIn('a Python variable', output.getvalue()) def test_dollar_command_result_is_available_inside_python_expressions(self): console, output, errors = self.open_console() - console.execute('$printf host-fixture') + console.submit('$printf host-fixture') self.assertEqual(output.getvalue(), 'host-fixture') - console.execute('print($.stdout)') - console.execute('hostname = $.stdout.strip()') - console.execute('status = $.exitcode') + console.submit('print($.stdout)') + console.submit('hostname = $.stdout.strip()') + console.submit('status = $.exitcode') self.assertEqual(console.namespace['hostname'], 'host-fixture') self.assertEqual(console.namespace['status'], 0) - console.execute('message = f"host={$.stdout.strip()}"') + console.submit('message = f"host={$.stdout.strip()}"') self.assertEqual(console.namespace['message'], 'host=host-fixture') - console.execute('out, err, status = $printf another-host') + console.submit('out, err, status = $printf another-host') self.assertEqual((console.namespace['out'], console.namespace['err'], console.namespace['status']), ('another-host', '', 0)) - console.execute('literal = "$.stdout is text, not evaluation"') + console.submit('literal = "$.stdout is text, not evaluation"') self.assertEqual(console.namespace['literal'], '$.stdout is text, not evaluation') self.assertEqual(errors.getvalue(), '') def test_dollar_commands_in_python_multiline_loop_and_function(self): console, output, errors = self.open_console() for line in ['for index in range(2):', ' $f"echo item-{index}"', '']: - console.execute(line) + console.submit(line) self.assertIn('item-0', output.getvalue()) self.assertIn('item-1', output.getvalue()) for line in ['def read_host():', ' $printf function-host', ' return $.stdout', '']: - console.execute(line) - console.execute('saved = read_host()') + console.submit(line) + console.submit('saved = read_host()') self.assertEqual(console.namespace['saved'], 'function-host') self.assertFalse(console.more) self.assertEqual(errors.getvalue(), '') @@ -198,25 +198,25 @@ def test_multiline_dollar_connection_waits_for_complete_expression(self): self.addCleanup(remote.close) console.namespace['host'] = 'fixture' with patch.object(parent.session, 'connect', return_value=remote) as connect: - console.execute('$.connect(') + console.submit('$.connect(') self.assertTrue(console.more) connect.assert_not_called() - console.execute(' host,') + console.submit(' host,') self.assertTrue(console.more) - console.execute(' username="ops",') - console.execute(')') + console.submit(' username="ops",') + console.submit(')') connect.assert_called_once_with('fixture', username='ops') self.assertFalse(console.more) self.assertEqual(len(console.layers), 2) - console.execute('exit()') + console.submit('exit()') self.assertEqual(errors.getvalue(), '') def test_syntax_error_recovers_for_next_input(self): console, _, errors = self.open_console() - console.execute('$.stdout + )') + console.submit('$.stdout + )') self.assertFalse(console.more) self.assertIn('SyntaxError', errors.getvalue()) - console.execute('value = 42') + console.submit('value = 42') self.assertEqual(console.namespace['value'], 42) def test_lexical_with_uses_normal_spy_scope_and_restores_stack(self): @@ -224,7 +224,7 @@ def test_lexical_with_uses_normal_spy_scope_and_restores_stack(self): original = console.current stack = patching.get_thread_stack().snapshot() for line in ['with $.shell("bash"):', ' $printf lexical-host', ' saved = str($.stdout)', '']: - console.execute(line) + console.submit(line) self.assertEqual(console.namespace['saved'], 'lexical-host') self.assertIs(console.current, original) self.assertEqual(patching.get_thread_stack().snapshot(), stack) @@ -234,10 +234,10 @@ def test_program_exit_returns_to_python_console_without_extra_exit(self): console, output, errors = self.open_console() original = console.current import shlex - console.execute('$.enter(' + repr(shlex.quote(sys.executable) + ' -q -u') + ', prompt=">>>", exit="quit()")') - console.execute('raise SystemExit') + console.submit('$.enter(' + repr(shlex.quote(sys.executable) + ' -q -u') + ', prompt=">>>", exit="quit()")') + console.submit('raise SystemExit') self.assertIs(console.current, original) - console.execute('value = 42') + console.submit('value = 42') self.assertEqual(console.namespace['value'], 42) self.assertEqual(errors.getvalue(), '') @@ -245,24 +245,24 @@ def test_nested_shell_exit_restores_parent_and_root_exit_closes(self): console, output, errors = self.open_console() original = console.current original_stack = patching.get_thread_stack().snapshot() - console.execute('$.shell("bash")') + console.submit('$.shell("bash")') self.assertEqual(len(console.layers), 2) - console.execute('exit()') + console.submit('exit()') self.assertIs(console.current, original) self.assertEqual(patching.get_thread_stack().snapshot(), original_stack) - console.execute('$printf parent-restored') + console.submit('$printf parent-restored') self.assertIn('parent-restored', output.getvalue()) - console.execute('exit()') + console.submit('exit()') self.assertTrue(console.done) self.assertTrue(original.session.closed) console.close() def test_dollar_session_and_wrapper_exit_are_removed_in_console(self): console, _, _ = self.open_console() - console.execute('$.shell("bash")') + console.submit('$.shell("bash")') current = console.current for source in ('$.exit()', 'session.exit()', 'console.exit()'): - console.execute(source) + console.submit(source) self.assertIsInstance(console.namespace['error'], AttributeError) self.assertIs(console.current, current) self.assertEqual(len(console.layers), 2) @@ -275,10 +275,10 @@ def test_connect_evaluates_literals_variables_kwargs_and_groups_shell(self): self.addCleanup(remote.close) console.namespace.update(host='fixture', options={'username': 'ops', 'port': 2222}) with patch.object(parent.session, 'connect', return_value=remote) as connect: - console.execute('$.connect(host, password="literal", **options)') + console.submit('$.connect(host, password="literal", **options)') connect.assert_called_once_with('fixture', password='literal', username='ops', port=2222) self.assertEqual(len(console.layers), 2) - console.execute('exit()') + console.submit('exit()') self.assertIs(console.current, parent) self.assertTrue(remote.closed) self.assertFalse(parent.session.closed) @@ -289,11 +289,11 @@ def test_failed_connection_shell_entry_rolls_back(self): remote = Session() with patch.object(parent.session, 'connect', return_value=remote), \ patch.object(remote, 'shell', side_effect=RuntimeError('fixture shell failure')): - console.execute('$.connect("fixture")') + console.submit('$.connect("fixture")') self.assertIsInstance(console.namespace['error'], RuntimeError) self.assertTrue(remote.closed) self.assertIs(console.current, parent) - console.execute('$true') + console.submit('$true') def test_identity_layers_route_parameters_and_unwind(self): # Use real shell layers as credential-free stand-ins for auth; native @@ -301,50 +301,50 @@ def test_identity_layers_route_parameters_and_unwind(self): console, _, _ = self.open_console() original = console.current with patch.object(original.console, 'sudo', side_effect=lambda **kw: original.console.shell('bash')) as sudo: - console.execute('$.sudo(password=None, username="fixture")') + console.submit('$.sudo(password=None, username="fixture")') sudo.assert_called_once_with(password=None, username='fixture') self.assertEqual(console.current.kind, 'sudo') - console.execute('$.connect("other")') + console.submit('$.connect("other")') self.assertIsInstance(console.namespace['error'], RuntimeError) self.assertIn('credentials', str(console.namespace['error'])) with patch.object(console.current.console, 'su', side_effect=lambda *a, **kw: original.console.shell('bash')) as su: - console.execute('$.su("other", password="fixture")') + console.submit('$.su("other", password="fixture")') su.assert_called_once_with('other', password='fixture') self.assertEqual(len(console.layers), 3) - console.execute('exit()') - console.execute('exit()') + console.submit('exit()') + console.submit('exit()') self.assertIs(console.current, original) def test_stop_returns_to_console_but_script_exit_cannot_pop_it(self): console, _, _ = self.open_console() original = console.current - console.execute('script = $.run("value=42\\n$.stop()\\nmissing=True")') + console.submit('script = $.run("value=42\\n$.stop()\\nmissing=True")') self.assertTrue(console.namespace['script'].stopped) self.assertEqual(console.namespace['script']['value'], 42) self.assertIs(console.current, original) - console.execute('$.run("$.exit()")') + console.submit('$.run("$.exit()")') self.assertIsInstance(console.namespace['error'], AttributeError) self.assertIs(console.current, original) - console.execute('exit()') + console.submit('exit()') self.assertTrue(console.done) def test_explicit_text_program_replies_and_exit_restore_shell(self): console, output, _ = self.open_console() original = console.current import shlex - console.execute('$.enter(' + repr(shlex.quote(sys.executable) + ' -q -u') + ', prompt=">>>", exit="quit()")') + console.submit('$.enter(' + repr(shlex.quote(sys.executable) + ' -q -u') + ', prompt=">>>", exit="quit()")') self.assertEqual(console.current.kind, 'program') - console.execute('print(6 * 7)') + console.submit('print(6 * 7)') self.assertIn('42', output.getvalue()) - console.execute('exit()') + console.submit('exit()') self.assertIs(console.current, original) - console.execute('$printf recovered') + console.submit('$printf recovered') self.assertIn('recovered', output.getvalue()) def test_keyboard_interrupt_stops_job_and_keeps_shell_usable(self): console, output, errors = self.open_console() import interactive - console.execute('$.set(check=True)') + console.submit('$.set(check=True)') real_sleep = interactive.time.sleep triggered = False caller_thread = threading.get_ident() @@ -356,10 +356,10 @@ def interrupt_once(seconds): raise KeyboardInterrupt real_sleep(seconds) with patch.object(interactive.time, 'sleep', side_effect=interrupt_once): - console.execute('$sleep 30') + console.submit('$sleep 30') self.assertIn('KeyboardInterrupt', errors.getvalue()) self.assertIsNone(console.namespace['error']) - console.execute('$printf after-interrupt') + console.submit('$printf after-interrupt') self.assertIn('after-interrupt', output.getvalue()) def test_eof_closes_and_errors_do_not_echo_secrets(self): @@ -380,13 +380,13 @@ def read(prompt): def test_help_keeps_current_environment(self): console, output, _ = self.open_console() original = console.current - console.execute('help') - console.execute('help()') + console.submit('help') + console.submit('help()') self.assertEqual(output.getvalue().count('Input is Python'), 2) self.assertIs(console.current, original) self.assertFalse(console.done) with patch('interactive.builtins.help') as python_help: - console.execute('help(str)') + console.submit('help(str)') python_help.assert_called_once_with(str) def test_exit_helpers_leave_one_layer_and_unwind_python_finally(self): @@ -394,18 +394,18 @@ def test_exit_helpers_leave_one_layer_and_unwind_python_finally(self): with self.subTest(statement=statement): console, _, errors = self.open_console() parent = console.current - console.execute('value=42') - console.execute('$.shell("bash")') + console.submit('value=42') + console.submit('$.shell("bash")') if statement == 'exit': - console.execute('exit # leave one environment') + console.submit('exit # leave one environment') else: for line in ('try:', ' exit()', 'finally:', ' cleaned=True', ''): - console.execute(line) + console.submit(line) self.assertTrue(console.namespace['cleaned']) self.assertIs(console.current, parent) self.assertFalse(console.done) self.assertEqual(console.namespace['value'], 42) - console.execute(statement) + console.submit(statement) self.assertTrue(console.done) self.assertTrue(parent.session.closed) self.assertEqual(errors.getvalue(), '') @@ -416,7 +416,7 @@ def test_program_layer_reserves_exit_and_quit_helpers(self): console, _, errors = self.open_console() parent = console.current self.enter_python(console) - console.execute(statement) + console.submit(statement) if statement.startswith('exit'): self.assertIs(console.current, parent) self.assertFalse(console.done) @@ -430,12 +430,12 @@ def test_quit_helpers_close_all_layers_and_unwind_python_finally(self): with self.subTest(statement=statement): console, _, errors = self.open_console() session = console.current.session - console.execute('$.shell("bash")') + console.submit('$.shell("bash")') if statement == 'quit': - console.execute('quit # finish all environments') + console.submit('quit # finish all environments') else: for line in ('try:', ' '+statement, 'finally:', ' cleaned=True', ''): - console.execute(line) + console.submit(line) self.assertTrue(console.namespace['cleaned']) self.assertTrue(console.done) self.assertTrue(session.closed) @@ -444,10 +444,10 @@ def test_quit_helpers_close_all_layers_and_unwind_python_finally(self): def test_script_exit_does_not_inherit_console_quit_helper(self): console, _, errors = self.open_console() - console.execute('value=42') + console.submit('value=42') original = console.current for source in ('exit()', 'quit()', 'import sys; sys.exit()'): - console.execute('$.run(' + repr(source) + ')') + console.submit('$.run(' + repr(source) + ')') self.assertIs(console.current, original) self.assertFalse(console.done) self.assertEqual(console.namespace['value'], 42) @@ -455,11 +455,11 @@ def test_script_exit_does_not_inherit_console_quit_helper(self): def test_helpers_can_be_shadowed_like_python_names(self): console, output, _ = self.open_console() - console.execute('quit = "a variable"') - console.execute('quit') + console.submit('quit = "a variable"') + console.submit('quit') self.assertIn('a variable', output.getvalue()) self.assertFalse(console.done) - console.execute('control.quit()') + console.submit('control.quit()') self.assertTrue(console.done) def test_ctrl_c_discards_multiline_input_and_preserves_namespace(self): @@ -480,23 +480,23 @@ def read(prompt): def test_python_execution_interrupt_keeps_console_and_variables(self): console, output, errors = self.open_console() - console.execute('value=42') - console.execute('raise KeyboardInterrupt') + console.submit('value=42') + console.submit('raise KeyboardInterrupt') self.assertFalse(console.done) self.assertFalse(console.more) - console.execute('print(value)') + console.submit('print(value)') self.assertIn('42', output.getvalue()) self.assertIn('KeyboardInterrupt', errors.getvalue()) def enter_python(self, console): import shlex - console.execute('$.enter(' + repr(shlex.quote(sys.executable) + ' -q -u') + ', prompt=">>>", exit="quit()")') + console.submit('$.enter(' + repr(shlex.quote(sys.executable) + ' -q -u') + ', prompt=">>>", exit="quit()")') self.assertEqual(console.current.kind, 'program') def test_ctrl_c_and_ctrl_d_go_to_program_and_restore_python_parent(self): console, output, errors = self.open_console() original = console.current - console.execute('value=42') + console.submit('value=42') self.enter_python(console) program = console.current events = iter([KeyboardInterrupt(), 'print(6*7)', EOFError(), 'print(value)', 'quit']) @@ -541,11 +541,11 @@ def interrupt_once(*args, **kwargs): raise KeyboardInterrupt return wait(*args, **kwargs) with patch.object(channel, '_wait_input_completion', side_effect=interrupt_once): - console.execute('import time; time.sleep(30)') + console.submit('import time; time.sleep(30)') self.assertEqual(console.current.kind, 'program') - console.execute('print(6*7)') + console.submit('print(6*7)') self.assertIn('42', output.getvalue()) - console._eof() + console.eof() self.assertIs(console.current, original) self.assertFalse(console.done) self.assertEqual(errors.getvalue(), '') diff --git a/src/sshscript/unittest/test_native_auth_runner.py b/src/sshscript/unittest/test_native_auth_runner.py index 171facc..12d72f5 100644 --- a/src/sshscript/unittest/test_native_auth_runner.py +++ b/src/sshscript/unittest/test_native_auth_runner.py @@ -25,8 +25,8 @@ def test_normal_discovery_never_runs_native_authentication(self): with patch.object(module, 'Session', side_effect=AssertionError('native session must not be created')): result = unittest.TestResult() unittest.defaultTestLoader.loadTestsFromTestCase(module.NativeAuthenticationTests).run(result) - self.assertEqual(result.testsRun, 9) - self.assertEqual(len(result.skipped), 9) + self.assertEqual(result.testsRun, 10) + self.assertEqual(len(result.skipped), 10) self.assertFalse(result.errors or result.failures) def run_fixture(self, local_report=None, timeout=False): diff --git a/src/sshscript/unittest/test_native_console_authentication.py b/src/sshscript/unittest/test_native_console_authentication.py index a088e8f..989321a 100644 --- a/src/sshscript/unittest/test_native_console_authentication.py +++ b/src/sshscript/unittest/test_native_console_authentication.py @@ -12,7 +12,7 @@ import time import unittest -from sshscript import Session +from sshscript import Session, Console, SwitchCredentials CONFIG = None @@ -57,6 +57,33 @@ def assert_recovery(self, shell, before, *, required): self.assertEqual(after, before, 'parent UID and shell PID must both survive') CONFIG['outcomes'].append({'case': self._testMethodName, 'recovery': 'confirmed'}) + def test_embedded_borrowed_session_native_switches_and_reuse(self): + outcomes = [] + def resolve(request): + password = CONFIG['target_password'] if request.kind == 'su' else CONFIG['password'] + return SwitchCredentials(password) + with Console(self.session, credential_resolver=resolve, + switch_observer=outcomes.append) as embedded: + before = embedded.environments[-1] + self.assertEqual(embedded.submit('saved = 42').state, 'completed') + for kind in ('su', 'sudo'): + if kind == 'su': + embedded.su(CONFIG['target_user'], enter_timeout=15) + else: + embedded.sudo(enter_timeout=15) + update = embedded.submit('$id -u') + self.assertEqual(update.state, 'completed') + self.assertEqual(update.result.stdout.strip(), self.target_uid if kind == 'su' else '0') + self.assertEqual(embedded.environments[-1].connection_user, self.session.username) + embedded.leave() + self.assertEqual(embedded.environments[-1], before) + self.assertEqual(embedded.submit('assert saved == 42').state, 'completed') + self.assertEqual(embedded.session_reuse, 'confirmed') + self.assertFalse(self.session.closed) + self.assertEqual(self.session(['id', '-u'], check=True).stdout.strip(), self.original_uid) + self.assertEqual([outcome.success for outcome in outcomes], [True, True]) + CONFIG['outcomes'].append({'case': self._testMethodName, 'recovery': 'confirmed'}) + def test_foreground_job_in_real_su_and_sudo(self): for kind in ('su', 'sudo'): context = (self.session.su(CONFIG['target_user'], CONFIG['target_password'], enter_timeout=15) diff --git a/src/sshscript/unittest/test_openssh_integration.py b/src/sshscript/unittest/test_openssh_integration.py index cf21858..29db9d2 100644 --- a/src/sshscript/unittest/test_openssh_integration.py +++ b/src/sshscript/unittest/test_openssh_integration.py @@ -17,7 +17,7 @@ import paramiko -from sshscript import Session, CommandTimeoutError +from sshscript import Session, CommandTimeoutError, Console ENABLED = os.environ.get("SSHSCRIPT_OPENSSH_TESTS") == "1" @@ -216,6 +216,15 @@ def test_real_nested_ssh_closes_child_and_preserves_parent(self): result = nested.exec_command(['printf', 'nested'], command_timeout=5, check=True) self.assertEqual(result.stdout, 'nested') self.assertEqual(result.host, self.host) + parent_transport = parent._client.get_transport() + with Console(nested) as embedded: + update = embedded.submit('$printf borrowed-jump') + self.assertEqual((update.state, update.result.stdout), ('completed', 'borrowed-jump')) + self.assertEqual(embedded.session_reuse, 'confirmed') + self.assertIs(nested._client.get_transport(), transport) + self.assertIs(parent._client.get_transport(), parent_transport) + self.assertFalse(nested.closed or parent.closed) + self.assertEqual(nested(['printf', 'reused'], check=True).stdout, 'reused') transport.join(2) self.assertFalse(transport.is_alive()) self.assertTrue(nested.closed) diff --git a/src/sshscript/unittest/test_public_types.py b/src/sshscript/unittest/test_public_types.py index f08bb0e..1d40493 100644 --- a/src/sshscript/unittest/test_public_types.py +++ b/src/sshscript/unittest/test_public_types.py @@ -3,6 +3,7 @@ import inspect from pathlib import Path import unittest +from interactive import Console from session import Session from commandjob import CommandJob from sessionwrapper import SessionWrapper @@ -11,7 +12,7 @@ class PublicTypeTests(unittest.TestCase): def test_public_stub_methods_exist_and_keywords_match(self): root = Path(__file__).resolve().parents[1] self.assertTrue((root / 'py.typed').is_file()) - for filename, cls in [('session.pyi', Session), ('commandjob.pyi', CommandJob), + for filename, cls in [('interactive.pyi', Console), ('session.pyi', Session), ('commandjob.pyi', CommandJob), ('sessionwrapper.pyi', SessionWrapper)]: tree = ast.parse((root / filename).read_text()) definition = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == cls.__name__) diff --git a/tools/check_public_types.py b/tools/check_public_types.py index 35e07e1..c7ff7d2 100644 --- a/tools/check_public_types.py +++ b/tools/check_public_types.py @@ -8,7 +8,7 @@ VALID = '''from typing import assert_type, Literal from pathlib import Path from paramiko import MissingHostKeyPolicy, AutoAddPolicy -from sshscript import Session, CommandResult, JobResult, CommandJob +from sshscript import Session, CommandResult, JobResult, CommandJob, Console, ConsoleUpdate, SwitchCredentials s = Session() s.set(check=True, log_level="DEBUG") s.verbose = False @@ -41,6 +41,11 @@ if isinstance(shell_result, CommandResult): assert_type(shell_result, CommandResult[int]) assert_type(shell_result.stdout, str) +with Console(s, credential_resolver=lambda request: SwitchCredentials(None)) as embedded: + assert_type(embedded.submit('x=42'), ConsoleUpdate) + assert_type(embedded.poll().stdout, str) + assert_type(embedded.connection, Session) + assert_type(embedded.environments[0].effective_user, str) with s.connect("host", known_hosts="local") as remote: assert_type(remote, Session) ''' diff --git a/tools/prepare_release.py b/tools/prepare_release.py index d02f71e..f8b1559 100644 --- a/tools/prepare_release.py +++ b/tools/prepare_release.py @@ -5,6 +5,7 @@ MODULES = 'interactive scriptcontrol __init__ _runtime _version sshscript session sessionsettings commandresult commandjob consolejob sshconfig patching errorutils tokenparser dollarparser dollarchanger spyimporter channelssh channelsubprocess channelutils channelgeneric sessionwrapper stdio dollar'.split() ROOT_FILES = [ + 'EMBEDDED_CONSOLE.md', 'examples/embedded_terminal.py', 'examples/embedded_agent.py', 'CONSOLE.md', 'AI_GUIDE.md', 'skills/sshscript/SKILL.md', 'skills/sshscript/references/agent-guide.md', 'API_GUIDE.md', 'VERSIONING.md', 'pyproject.toml', 'README.md', 'LICENSE.txt', 'RELEASING.md', @@ -40,6 +41,7 @@ 'test_native_console_authentication.py', 'test_native_auth_runner.py', 'test_console_results.py', + 'test_embedded_console.py', 'test_interactive_console.py', 'test_console_job.py', 'test_dollar_command_escapes.py', @@ -125,7 +127,7 @@ def prepare(source, destination): bootstrap = source / 'src/_sshscript_cli.py' if (source / 'src/sshscript').is_dir() else source / '_sshscript_cli.py' pairs.append((bootstrap, destination / 'src/_sshscript_cli.py')) pairs += [(package / 'unittest' / p, destination / 'src/sshscript/unittest' / p) for p in TESTS] - pairs += [(package / p, destination / 'src/sshscript' / p) for p in ('py.typed', '__init__.pyi', 'session.pyi', 'sessionwrapper.pyi', 'commandjob.pyi', 'commandresult.pyi')] + pairs += [(package / p, destination / 'src/sshscript' / p) for p in ('py.typed', 'interactive.pyi', '__init__.pyi', 'session.pyi', 'sessionwrapper.pyi', 'commandjob.pyi', 'commandresult.pyi')] missing = [str(src) for src, _ in pairs if not src.is_file()] if missing: raise FileNotFoundError('Missing release inputs: ' + ', '.join(missing)) diff --git a/tools/sync_ai_docs.py b/tools/sync_ai_docs.py index 9646313..1b02848 100644 --- a/tools/sync_ai_docs.py +++ b/tools/sync_ai_docs.py @@ -29,6 +29,10 @@ def outputs(): api = (ROOT / 'API_GUIDE.md').read_bytes() versioning = (ROOT / 'VERSIONING.md').read_bytes() console = (ROOT / 'CONSOLE.md').read_bytes() + embedded = (ROOT / 'EMBEDDED_CONSOLE.md').read_bytes() + api = api.replace(b'(EMBEDDED_CONSOLE.md)', b'(embedded-console.md)') + versioning = versioning.replace(b'(EMBEDDED_CONSOLE.md)', b'(embedded-console.md)') + console = console.replace(b'(EMBEDDED_CONSOLE.md)', b'(embedded-console.md)') # The snapshots travel together; repair their mutual documentation link. api = api.replace(b'(VERSIONING.md)', b'(versioning.md)') api = api.replace(b'(CONSOLE.md)', b'(console.md)') @@ -42,6 +46,9 @@ def outputs(): ('sshscript/references/api-guide.md', api), ('sshscript/references/versioning.md', versioning), ('sshscript/references/console.md', console), + ('sshscript/references/embedded-console.md', embedded), + ('sshscript/references/examples/embedded_agent.py', (ROOT / 'examples/embedded_agent.py').read_bytes()), + ('sshscript/references/examples/embedded_terminal.py', (ROOT / 'examples/embedded_terminal.py').read_bytes()), ): info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) info.compress_type = zipfile.ZIP_DEFLATED diff --git a/tools/sync_api_guide.py b/tools/sync_api_guide.py index 2f2a724..6b4e3da 100644 --- a/tools/sync_api_guide.py +++ b/tools/sync_api_guide.py @@ -25,6 +25,7 @@ def main(): content = HEADER + (Path(__file__).resolve().parents[1] / 'API_GUIDE.md').read_text() content = content.replace('(VERSIONING.md)', '({{ site.baseurl }}/v3a/migration-and-releases/version-policy/)') content = content.replace('(CONSOLE.md)', '({{ site.baseurl }}/v3a/reference/interactive-console/)') + content = content.replace('(EMBEDDED_CONSOLE.md)', '({{ site.baseurl }}/v3a/reference/embedded-console/)') if args.check: existing = re.sub(r"\nLast Updated: [^\n]+\n?$", "", args.destination.read_text()) if existing.rstrip() != content.rstrip(): From d27336a5f8bbdfd7adf6122c932c544d9d698bd6 Mon Sep 17 00:00:00 2001 From: iap Date: Fri, 23 Oct 2026 18:56:01 +0800 Subject: [PATCH 2/3] Allow full CI time budget for Console regressions --- .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55f03dc..a2f14ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,7 @@ permissions: contents: read jobs: verify: - timeout-minutes: 20 + timeout-minutes: 30 strategy: fail-fast: false matrix: @@ -48,7 +48,7 @@ jobs: openssh-integration: name: OpenSSH integration (Python ${{ matrix.python-version }}, ${{ matrix.dependency-label }}) runs-on: ubuntu-latest - timeout-minutes: 25 + timeout-minutes: 35 strategy: fail-fast: false matrix: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7596f4e..e5b7048 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,7 +12,7 @@ jobs: build: name: Verify and attest release artifacts runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 30 permissions: contents: read id-token: write From 496382abb1e84cff1e539362394300a0328d62ba Mon Sep 17 00:00:00 2001 From: iap Date: Fri, 23 Oct 2026 19:09:35 +0800 Subject: [PATCH 3/3] Make program-start assertions portable across PTY output layouts --- src/sshscript/unittest/test_embedded_console.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/sshscript/unittest/test_embedded_console.py b/src/sshscript/unittest/test_embedded_console.py index 38cbe10..19d4a4e 100644 --- a/src/sshscript/unittest/test_embedded_console.py +++ b/src/sshscript/unittest/test_embedded_console.py @@ -227,7 +227,7 @@ def test_running_program_interrupt_recovers_prompt(self): update = console.send_input('import time; print("sleep-started", flush=True); time.sleep(30)', timeout=.3) self.assertEqual(update.state, 'running') - self.assertIn('\r\nsleep-started\r\n', update.stdout + console.poll().stdout) + self.assertIn('sleep-started\n', (update.stdout + console.poll().stdout).replace('\r\n', '\n')) update = console.interrupt() self.assertEqual(update.state, 'waiting_input') self.assertEqual(console.poll().state, 'waiting_input') @@ -242,7 +242,7 @@ def test_program_ignoring_interrupt_stays_running_until_real_prompt(self): console.send_input('import signal, time; signal.signal(signal.SIGINT, signal.SIG_IGN)') update = console.send_input('print("ignoring-started", flush=True); time.sleep(4)', timeout=.3) self.assertEqual(update.state, 'running') - self.assertIn('\r\nignoring-started\r\n', update.stdout + console.poll().stdout) + self.assertIn('ignoring-started\n', (update.stdout + console.poll().stdout).replace('\r\n', '\n')) update = console.interrupt() self.assertEqual(update.state, 'running') self.assertEqual(console.poll().state, 'running') @@ -259,7 +259,7 @@ def test_program_interrupt_delayed_prompt_recovers_via_poll(self): source = 'import time\ntry:\n print("delay-started", flush=True); time.sleep(30)\nexcept KeyboardInterrupt:\n time.sleep(3)' update = console.send_input('exec(' + repr(source) + ')', timeout=.3) self.assertEqual(update.state, 'running') - self.assertIn('\r\ndelay-started\r\n', update.stdout + console.poll().stdout) + self.assertIn('delay-started\n', (update.stdout + console.poll().stdout).replace('\r\n', '\n')) update = console.interrupt() self.assertEqual(update.state, 'running') update, _ = self.complete(console, update)