Skip to content

Cross-transport contracts

Host implementations share a deliberately small, subprocess/pathlib-shaped contract. A provider may advertise only the capabilities it can implement; callers should inspect host.capabilities before requesting optional operations.

Command execution

Host.run(*cmds) returns subprocess.CompletedProcess and captures stdout and stderr by default. Captured streams are bytes unless text=True, an encoding, or an errors= is supplied -- errors= alone selects text mode on every transport, which is the rule 0.2.6 made uniform. A CAPTURED stream that produced no output is b"", never None; a stream excluded by capture_output=False (or routed to a target) is None, exactly as subprocess reports one it did not capture.

  • A string is shell source and remains verbatim (operators such as &&, pipes, and redirects are not quoted).
  • A tuple/list is argv data and each item is quoted by the selected shell.
  • Exec(program, *args) is the only direct-execution spelling: one program with an argv, never interpreted by a shell, and never combined with other commands. A path anywhere else is an ordinary value that stringifies.
  • Multiple top-level commands are joined with the shell's sequence separator.
  • env is additive to the provider's environment, on every transport including the local one. cwd is the process working directory. Where env is embedded in the command text, exception text, repr() and CompletedProcess.args show each value as <redacted>. check defaults to True, the opposite of subprocess.run: a non-zero status raises CalledProcessError unless the caller passes check=False. A provider which cannot obtain a status must use -1, never None.
  • timeout raises subprocess.TimeoutExpired and the provider must make a best-effort attempt to terminate and close the child. The exception carries the same payload on every transport: .orphaned (True when the command could not be stopped and is still running), .pid (None unless the transport knows one), and .output/.stderr as empty rather than None.
  • timeout=None is no limit for the call. Left out, timeout is the config's timeout.total, and when that is DEFAULT, the transport's own default (none, except WinRM's 30 s). Every config spells its timeouts as one Timeout(total, connect, read, write) and refuses a field it cannot honour; the rows below say why each transport leaves a field out.

Persistent processes

Host.spawn() returns a synchronous Process. read(n) returns up to n available bytes (n < 0 reads until EOF), read_stderr mirrors it, and EOF is represented by b"". wait() is idempotent and returns an integer status; close() is idempotent. Implementations with a text encoding decode only at complete character boundaries. send_eof, resize, and terminal allocation are optional capabilities and raise NotImplementedError when unavailable.

Paths

Host.path() returns a pathapi.Path. Missing paths raise FileNotFoundError; reading a directory raises IsADirectoryError; scanning a file raises NotADirectoryError. exists() is a boolean probe and never raises for a dangling symlink/reparse point. Binary and text writes must round-trip, including empty files and multibyte text split across transfer chunks.

symlink_to(target, target_is_directory=False) and readlink() follow the pathlib.Path signatures. readlink() reports the target the transport stored, verbatim: a relative target stays relative and is never resolved against the link's parent. stat() follows links by default and stat(follow_symlinks=False) does not, so is_symlink() and exists() always agree.

Local, SFTP (SSH), WinRM, and container archive paths implement both. QGA paths raise NotImplementedError because the guest agent exposes no symlink RPC. Creating a symbolic link on a Windows target additionally requires an elevated session or Developer Mode; without one the backend raises PermissionError, which is a host policy rather than a transport gap.

target_is_directory is a local-Windows-filesystem hint with no wire representation, so every remote backend accepts and ignores it.

Capability selection

The conformance tests are capability-driven. A skipped test must state the missing capability (pytest -rs); providers must not silently skip behavior they advertise.

Divergence ledger

Provider/operation Deliberate divergence Rationale
Serial transport raw profiles expose sessions only; prompt profiles opt into run only with explicit status framing; the POSIX profile frames output, stderr and status itself; no filesystem A serial byte stream has no portable command protocol. Profiles own login, prompts, line endings, and completion markers.
Console PromptConsoleProfile.run() the output window starts after the device's echo of the command; no stdin (input= and stdin= raise); stdout and stderr merged; the status is whatever the device's marker says; the command is typed as keystrokes A device console has no syntax the library can impose, so the echo is the only boundary it has. A device that redraws, wraps or edits the echoed line moves the window; a ^C, TAB or ? in the command is acted on by the device's line editor; bytes typed for a command that does not read them are read by the console as its next line, so stdin cannot be streamed safely. Use PosixConsoleProfile wherever the far end is a POSIX shell.
Executor ConsoleExecutor one command at a time per console (another call waits within its own timeout); stderr arrives when the command ends; a timeout is orphaned=True unless a subclass's _abandon ends the command; no native args/cwd/env A terminal carries one foreground command, and the framed protocol reads stderr from a file after the command exits. ^C is the only interrupt a terminal has and a program may ignore it; only the owner of the session (closing it hangs up its jobs) can promise more. The host renders one line of shell text, run in a child shell so the console's own shell is left as it was.
Console PosixConsoleProfile(batch=None) typed input is not acknowledged, so a stream that drops what overflows (a serial line's 4 KiB input queue) corrupts the command; a pty driven from one thread can stall The acknowledgement is what bounds the bytes in flight. Only the caller knows whether its stream blocks the writer and whether something keeps reading the terminal's echo, so None is opt-in.
Console PosixConsoleProfile batch at most 1 KiB is typed before each acknowledgement, a round trip per KiB of command and input; a larger batch= stalls a macOS pty driven from one thread A profile reads the console only between its own writes, so what is typed but not yet read by the shell must fit what the far terminal holds while its echo goes unread: about 1.3 KiB on a macOS pty (sh, bash and zsh measured), 8 KiB or more on Linux, 4 KiB on a serial line. The far end's limit cannot be queried, so the default is the smallest measured.
Console PosixConsoleProfile stdin is sent whole (a stdin= stream is read to its end first) and stderr is delivered when the command ends; the console is shared with anything else writing to that terminal; a timed-out command gets a ^C and its temporary directory stays; output is exact only over an 8-bit line without XON/XOFF stdin travels as a file because the terminal cannot carry it safely, and stderr goes through a file because a POSIX shell has no portable second channel to a terminal. Kernel messages or another process writing to the same console land inside a frame; nothing on the wire tells them apart. ^C is the only interrupt a terminal has, and the cleanup it skips is the command's own. Software flow control consumes 0x11/0x13 before the bytes reach either end. A program that opens /dev/tty itself (a password prompt) reads what is typed next.
Local uncaptured output inherits the parent's file descriptor rather than writing to sys.stdout That is subprocess.run's own stdout=None behaviour and it costs nothing to keep: the child writes where the parent writes. A buffered transport has the bytes in hand instead, so it must route them, and dispatch_output sends them to sys.stdout.
Container timeout.total and run(timeout=<n>) ContainerConfig(timeout=<n>) raises ValueError, run(timeout=<n>) raises NotImplementedError; run(timeout=None) and an omitted timeout run unbounded Docker exec exposes no cancellable command deadline, so a timeout there could only be a read deadline that leaves the command running -- which is not what the keyword means anywhere else. Bounding the buffered exec_run from outside would also leave the thread reading its one HTTP response blocked until the command ends.
Container timeout.connect is the Docker SDK client's request timeout (60 s): it bounds connecting to the Engine and also each Engine API reply, though not an exec's output stream docker-py has one timeout, handed to requests as both the connect and the read limit of every API call, and it lifts it on exec output sockets itself. Splitting it into a (connect, read) pair would break the SDK, which adds to it as a number.
Container, Local and system configs timeout.read/timeout.write raise ValueError A local subprocess, a buffered Engine exec and a system host's dispatch have no per-reply or per-write step hostctl can bound; the only bound on them is the whole command. A system config's connection steps belong to the transport configs in provider_options, so it honours total only, and at DEFAULT leaves the provider its own (a WinRM provider its config's 30 s).
SSH timeout.read/timeout.write raise ValueError A buffered exec over asyncssh reads until the channel closes; asyncssh offers no per-read bound on it, and a silent command is not a failure. Keepalives belong in connect_options.
WinRM deadline (run(timeout=), the config's total, 30 s by default) every leg raises TimeoutExpired with orphaned=True; pywinrm notices the deadline between WinRS Receives, up to 20 s late; a None total and no per-call timeout=, or run(timeout=None), leave a command unbounded Termination is requested, never confirmed: the native leg can only kill its local powershell.exe relay, pywinrm sends the WinRS terminate signal and deletes the shell, PSRP sends its stop signal, and none of them reports whether the remote process ended. pywinrm's Protocol fixes the WS-Man operation timeout of every Receive when it is built, so a silent command holds each one for up to that 20 s; a PSRP Receive takes its own timeout and is shortened to the time left. A host that stops answering mid-Receive costs read windows instead: one 30 s read limit on pywinrm, up to six on PSRP (pypsrp retries a Receive's read timeout five times). With no total the native leg's only bound is timeout.connect as -OpenTimeout, which covers opening the session and not a host that stops answering afterwards.
WinRM timeout.read/timeout.write raise ValueError; the per-request read limit (30 s) and server hold (20 s) are fixed Each Receive is held by the server for up to the WS-Man operation timeout while a command prints nothing, so a read limit at or under that hold ends every quiet command as a client-side read timeout. Exposing it would invite exactly that value; the total is the bound a caller wants.
Native WinRM timeout.connect is New-PSSessionOption -OpenTimeout: it bounds opening the remote session; the per-request timing the other legs use is not applied The local Invoke-Command exposes no per-request timeout. Against Windows PowerShell 5.1 and a listener that accepts TCP and never answers, -OpenTimeout of 5, 60 and 180 s (the default) ended the call after 18.5, 70.5 and 190.4 s; -OperationTimeout 5000 alone did not shorten it (190.5 s), so there is no per-request bound to pass the server hold onto.
pywinrm timeout.connect bounds the connect of every HTTP request, not one session open; an expiry is a ConnectionError that can come mid-command, with the command's effect unknown pywinrm opens no session: each WS-Man request goes through requests, which reconnects whenever the kept-alive connection was dropped, and pywinrm hands every request the same (connect, read) timeout.
QEMU timeout.connect bounds opening a unix or ssh agent stream and its guest-sync; at DEFAULT it shares the deadline of the first request; refused with transport='libvirt' and with transport_factory; timeout.write raises libvirt opens its own connection to the daemon and virConnectOpen takes no timeout, and a transport_factory builds a transport hostctl does not configure. A guest-agent request is one JSON line written and one read back, so read bounds the whole round trip and there is no separate write step. The qga+ssh tunnel's SSH connection is the ssh= config's own timeout.connect.
Serial timeout.connect and inter_byte_timeout connect raises ValueError; inter_byte_timeout stays a SerialConfig field of its own Opening a serial port does not wait on a peer and pyserial takes no open timeout. inter_byte_timeout is the longest gap between two bytes of ONE read, a framing setting that shortens a read rather than bounding an operation, so it is not a Timeout field.
timeout.read=0 / timeout.write=0 serial only: pyserial's non-blocking read and write; every other config raises ValueError naming the field; total and connect refuse zero everywhere Only a serial port can return at once with what it has: a guest-agent reply, a connection or a command either arrives or it does not, so a zero there could only fail immediately.
PSRP run() text stdout is rendered by Out-String -Stream in the runspace at a hostless runspace's 120 columns, where pywinrm and native use the far end's console width; stderr is each error record's message, not the console's error view A runspace has no console, so the width is the runspace's own, and error records arrive on a separate typed stream that carries no rendered view.
Serial framed run() output past max_buffer raises instead of truncating A console transcript cut from the front mid-line and returned as complete is a corrupt result reported as success, and error_patterns in the discarded part stopped setting a status. The cap is a profile setting; raising it is the caller's decision to make, not the library's to make silently.
Serial process read(-1) returns bytes currently reported as available, capped at 64 KiB, rather than waiting for EOF; a text-mode read returns "" while a character is incomplete and keeps it for the next read Physical and network serial ports normally have no EOF until disconnected; waiting for EOF would make interactive sessions unusable.
QGA run() captured output may be truncated; the result carries stdout_truncated/stderr_truncated and a RuntimeWarning qemu-ga caps what it captures and reports the cap in its status reply. There is no second chance to fetch the rest, so the result says it is incomplete instead of looking whole.
QGA info().os_family a family (linux, windows, freebsd), never the distribution guest-get-osinfo answers id with an os-release id (ubuntu, rhel); the family comes from kernel-name, so the same machine reads the same over QGA as over SSH. id and pretty-name feed os_name.
QGA Windows path helper PowerShell reads each script from stdin (-Command -), not from an -EncodedCommand argument Endpoint protection scans the command line and flags a base64 PowerShell launch. guest-exec carries stdin as input-data, so nothing is lost; the helper's scripts need no stdin of their own.
Serial run() with several commands with a profile that names no shell flavour (raw, prompt, custom): one framed exchange each, output concatenated, the LAST status reported; with PosixConsoleProfile: one script in its flavour, one exchange, as on any POSIX host Joined with the profile's separator they went out as one line, which most device consoles parse as a single malformed command -- and a profile that frames status per exchange could only report one status for all of them. A POSIX shell parses a script, so there the commands and any ShellOperator between them join as they do everywhere else.
Serial run() with a structured command, an Exec, cwd= or env= raises NotImplementedError unless the profile declares a shell_flavour (PosixConsoleProfile does; raw and prompt profiles do not) Without a declared flavour there is no quoting rule to apply; shlex.join put POSIX quotes on a device that takes them literally. The profile owns the device's grammar -- pass the command as text.
Serial cwd=/env= with PosixConsoleProfile embedded, and the script runs in a subshell ( ... ); a command run without them runs in the console's own shell, where cd and variables persist The console's shell outlives the call, so an embedded cd or export would apply to every later command, where on any other host each run is a fresh shell. A command without them keeps the console's documented persistence.
Serial spawn() reads bytes, unless encoding= or errors= is passed A console is a byte stream and the profile's encoding is a write-side default; promoting it to a read mode would turn every existing session into a text one. With either keyword the reads decode, as they do on the SSH and QEMU console adapters.
SSH to a Windows target with the cmd dialect the rendered command line is caret-escaped a second time, for run() and spawn() alike AsyncSSH sends one command STRING and the server hands it to a login shell. Windows OpenSSH's stock server uses cmd.exe /c, so a cmd command line -- which already carries one cmd layer -- is parsed by cmd twice. Measured against a real cmd.exe: unescaped, 4 of 8 adversarial values survive and two of the failures are command injection. Set SSHConfig(login_shell=...) when the server's DefaultShell is not the stock one. The PowerShell dialect sends -Command under a cmd login shell (a one-line script) or a POSIX one (always), and a script with a # comment on the channel's stdin (-Command -) when the call has no input=/stdin=; only a multi-line script, or one whose stdin is taken, uses -EncodedCommand. Why: a newline cannot cross cmd.exe /c, PowerShell reads stdin line by line and drops a multi-line block, and -EncodedCommand writes CLIXML to stderr (decoded for run(), left as is for a spawned process) and is flagged by endpoint protection, which may refuse a long one.
SSH relative path resolves against the SFTP login directory (realpath(".")), asked once per connection through an SFTP session on the exec connection, so building a relative path connects; a failure to connect declines the provider, and the next path provider then reads the same relative name against its OWN base (a local provider: this process's working directory), so it may name a different file sftppath exposes no working directory, and a relative name must mean what run()'s working directory means. A provider that cannot connect has no base to lend the next one; a caller mixing providers whose bases differ passes absolute paths.
SSH paths and the connection paths open SFTP channels on the one connection run() uses, so timeout.connect, ssh_config and connect_options apply to it once and a host authenticates once; each SFTP request (opening the channel, stat, open, mkdir, ...) is bounded by sftppath's 60 s, while recursive copy()/rm() and streamed reads and writes are not, and timeout.total does not apply to paths; a path taken before close() runs on the next connection, where a container or QGA path refuses A path operation is not one command with a deadline to inherit, and sftppath owns the per-request bound; a dead peer during a long transfer is asyncssh's keepalive (connect_options). An SSH path's backend resolves the host's current connection on every request, so after close() it has a connection to reopen.
Local path() with no segments returns a path with no segments added (.), not the absolute working directory It was the one host that added a segment, so host.path() meant two different things depending on which host you had. The relative path still resolves against this process's working directory, so using it is unchanged -- only its text. A local path is also a plain pathapi path rather than a composite: one provider reaching one filesystem has nothing for a composite to decide.
QEMU console read() returns one chunk (64 KiB for read(-1)), and an empty receive is treated as stream EOF: it ends the console lease and every later read returns the empty value A libvirt console stream has no EOF of its own until the peer goes away, so waiting for one would make an interactive console unusable. An empty receive from a blocking console stream means the peer closed; the lease is released so the console can be opened again, and reads stay readable rather than raising.
WinRM persistent process spawn/TTY unavailable WinRM's buffered command API does not expose a durable bidirectional stream.
run(check=) and run(capture_output=) both default to True, where subprocess.run defaults them to False A remote command that fails is an error by default rather than a return value a caller may forget to inspect, and its output is captured rather than inherited by a process that may have no console. Pass check=False to read returncode yourself.
Batch files (.bat/.cmd): local Exec, cmd and both PowerShell flavours each argument is one batch token: wrapped in ", " written "", % written %%cd:~,%, trailing backslashes doubled; a " or % in a local Exec program path raises Windows dispatches a batch target through cmd.exe, which parses the argument again (CVE-2024-24576 class), so the token must be inert to cmd and survive the batch file's own %*. "" is the only quote cmd keeps balanced. A Microsoft C runtime reads "" as one "; a consumer using CommandLineToArgvW or the system msvcrt.dll (node, robocopy and other system tools) may split a value that has whitespace after a quote. It never executes. The program path is read by CreateProcess before cmd does, so no escape serves both.
Program of unknown type (a bare name, .ps1, other PATHEXT types) cmd raises ValueError for an argument containing "; PowerShell raises for one containing any of " & \| < > A bare name may resolve to a batch file, and a value PowerShell passes unquoted (no space) would then be executed by cmd. Name the program with its .exe or .bat suffix, use Cmdlet for a PowerShell cmdlet, or use Exec. %, ^ and ! are not refused: a batch file corrupts them (%USERNAME% is expanded) but they cannot start a command.
Windows PowerShell 5 native arguments raises ValueError for a value whose every whitespace follows an odd number of " PS 5 wraps a native argument in quotes only when some whitespace sits at an even count of " (counting the \" hostctl emits), so such a value reaches the child split. "" inside quotes would work for the Microsoft C runtime but not for CommandLineToArgvW or msvcrt.dll consumers. PWSH and Exec pass the value unchanged.
hostctl.deploy on a host without POSIX paths (WinRM, a Windows container or guest, a PowerShell local host) raises NotImplementedError before anything is sent The install is POSIX programs (mv, rm, mkdir, chmod, tar --strip-components) and a tree's launcher is a /bin/sh script. Its atomic step is a rename over the installed file, which POSIX allows while a process runs it and Windows refuses for an open file, so a PowerShell rendering of the same steps could not keep that guarantee.
PowerShell native argument --% raises ValueError PowerShell 5.1 and 7 read a native argument whose value is --% as the stop-parsing token however it is spelled ('--%', "--%", $('--%') all do), so the rest of the line is passed unparsed. No spelling sends the value; a cmdlet argument is not affected (Cmdlet).
Windows PowerShell 5 PIPE/REDIRECT/APPEND between native programs bytes are re-encoded: a pipe goes through the console code page, >/>> write UTF-16LE with a byte-order mark A property of the engine: measured, 41e980ff0a through a pipe arrived as 413f3f3f0d0a. PowerShell 7 and cmd are byte-exact. A pipe between cmdlets is PowerShell's normal use and is correct, so the operator is not refused.
PowerShell 7 REDIRECT/APPEND target containing [, ], * or ? rendered as >/>>, so after cmdlet output pwsh reads the target as a wildcard: it fails when nothing matches, or writes the file that does; after a native program the target is literal pwsh's > after a native program is literal and byte-exact, which Out-File is not (measured: 41e980ff0a arrives as 41ce98c387c2a00d0a, decoded through the OEM code page with CRLF added), and a backtick-escaped > target is not found at all. Byte-exact native redirection is kept; for cmdlet output to such a name, pipe to Out-File -LiteralPath yourself. Windows PowerShell 5 renders every path target as Out-File -LiteralPath, since its > re-encodes anyway.
pwsh output rendering a PWSH script started through invocation() or command() (local, container, QGA, SSH) begins with if ($PSStyle) { $PSStyle.OutputRendering = 'PlainText' } pwsh 7.2+ writes ANSI colour into formatted tables and error records when TERM names a colour terminal, even into a pipe, so captured output differed from Windows PowerShell 5's (\x1b[32;1ma\x1b[0m for a). Captured output is the command's text on every transport and dialect; a script that wants colour sets OutputRendering itself.
Windows PowerShell 5 relative program path containing [, ], * or ? rendered as given, so PS 5 may resolve it as a wildcard and run a matching sibling (.\[ab]\t.cmd runs .\a\t.cmd when that exists) Measured on 5.1: a backtick-escaped relative path is not found at all ([ab]\t.cmd is then read as a module-qualified name), and Convert-Path reports the current location escaped when it contains a wildcard character, so no spelling resolves a relative path literally. An absolute path is escaped and runs the named file; pwsh and Exec read every path literally.
Embedded env value "" under Windows PowerShell 5 and cmd the variable is unset $env:K='' and set K= delete the variable on those engines and neither has a spelling for an empty one; pwsh and POSIX set it empty, and a provider that carries env natively (local) keeps it empty. Refusing "" would make a value that works natively fail when embedded.
Embedded env= (SSH, WinRM, PSRP, QGA, serial, any provider without native env) the value travels in the command the remote side runs, and is visible in its process table The transport has no confidential environment channel: sshd drops environment requests unless AcceptEnv names them, and QGA's env replaces the environment. hostctl masks the value in exception text, repr(), CompletedProcess.args and the DEBUG dispatch log; it cannot hide it from the remote host.
Local timeout= from a terminal a timed-out command started from a process that has a controlling terminal may leave grandchildren running A new session (needed so one signal reaches the whole tree) drops the terminal, so a child opening /dev/tty failed. A new process group would keep it but run the child in the background, where reading the terminal stops it. Without a terminal the child still gets its own session and the whole tree is killed.
cmd structured call raises ValueError call re-expands % and doubles ^ in its arguments (measured: %USERNAME% became the user name, c^d became c^^d), so no single quoting survives it. Run the program directly; a .bat/.cmd already gets the batch rule without call.
cmd structured start always renders an empty title, start "" ...; the tail is program and arguments start reads its first quoted word as the window title, so a quoted program path ran as a title and nothing started. A structured start therefore never takes a title of its own; raw text still can.
cmd and invocation() raises NotImplementedError A cmd script cannot survive argv delivery: the platform's own command-line quoting escapes the quotes cmd's parser needs, and no argv element can encode an unescaped one. Render with command() and submit the result as an executor.CommandLine.
QGA direct Exec with cwd=/env= raises NotImplementedError guest-exec has no cwd at all, and its env list is passed to the guest as envp, which replaces the environment rather than adding to it. A direct argv execution has no shell to embed additive assignments into, so it refuses rather than running with a different meaning than every other transport.
QEMU Guest Agent timed-out guest processes cannot be cancelled QGA exposes process IDs and polling but no portable kill operation in the buffered contract.
Container run() no stdin/input=, and timeout=<n> raises NotImplementedError Docker Engine's buffered exec_run does not stream stdin, and the exec API exposes no cancellable deadline -- a timeout there could only be a read deadline that leaves the command running, which is not what the keyword means anywhere else.
Container iterdir()/walk() pulls one archive per directory, recursively Docker's get_archive answers with the whole subtree, so a walk re-downloads the children at every level. It is correct and it is O(depth) in transfer; a large tree is cheaper to list through run().
Container path mutations unsupported operations raise NotImplementedError Docker archive APIs provide safe file transfer but not all remote filesystem metadata primitives. Symlinks are the exception: a SYMTYPE tar member is a faithful archive representation, so symlink_to()/readlink() are implemented.
QGA symlink_to()/readlink() raise NotImplementedError The guest agent's guest-file-* protocol has no symlink RPC; emulating one through guest-exec would be a different transport with different permission semantics.
WinRM symlink_to() may raise PermissionError Windows requires an elevated session or Developer Mode to create a symbolic link. This is a host policy, not a transport gap, so the privilege failure is normalized rather than reported as an unsupported operation.
LocalPath on Windows opening a directory may surface PermissionError CPython's Windows file API reports EACCES; remote providers normalize this to IsADirectoryError.
QGA write_bytes()/open("w") written in place, not atomically; exclusive=True writes a temporary file and renames it without replacing In place is what keeps the file's inode, mode, owner and links, as a local open("w") does. QGA has no atomic create-if-absent RPC, so exclusive creation needs a temporary and a no-replace rename. A new file gets the guest agent's creation mode.
WinRM paths and Windows QGA guests: _utime() a PowerShell script sets LastWriteTimeUtc/LastAccessTimeUtc to NTFS's resolution, 100 ns, truncated; a read-only file's attribute is cleared for the call and restored; stat() reports whole seconds Windows PowerShell 5.1 (.NET Framework) opens a file for writing to set a time, which a read-only file refuses (pwsh 7 does not). Truncating keeps a time in its own second, the resolution stat() reports and PathSyncer's quick check compares at.
POSIX QGA guests: _utime() needs the probed guest helper, without which supports(path, Utime) is False; stat -L, then touch -c -d @SECONDS.NANOSECONDS: nanoseconds on GNU coreutils, whole seconds (floored) on busybox; stat() reports whole seconds QGA's file RPCs have no time call. touch -c exits 0 for a missing path, so the stat is what makes one FileNotFoundError; busybox reads @N.F as an invalid date.
Container _utime() an exec in the container, not an archive call: touch as uid 0 on Linux (as POSIX QGA guests), the WinRM PowerShell script on a Windows container; a container without the program, or a client without exec_run, answers supports(path, Utime) False; stat() reports whole seconds The archive API has no time call, and re-putting the member with a new tar mtime would send the whole content again to change one header field. uid 0 because put_archive writes as root, so a file it wrote is one the exec may change. One exec probe per path backend.
QGA rename() replaces an existing target on a POSIX guest, raises FileExistsError on a Windows guest The same rule os.rename follows on each family.
Container write_bytes() an overwrite keeps the owner and mode from the existing tar header; a new file is uid/gid 0, mode 0644, and ContainerConfig(username=) does not apply The archive API takes ownership only from the tar header, and the configured user is an exec setting the engine never sees on put_archive.
QGA run(stderr=STDOUT) interleaved only on qemu-ga 8.0+ with a non-Windows guest; otherwise stdout followed by stderr Older agents capture the two streams separately, and merged is compiled out of the Windows agent.
WinRM pywinrm and PSRP, Container: verify=True on Python 3.10+ with truststore (installed by the extras), the platform's own verifier decides -- Windows CryptoAPI, which fetches a public root it has not stored yet as Schannel does, the macOS Security framework with the keychains, OpenSSL with the system CA paths elsewhere -- so on Windows it trusts what the native WinRM leg (Schannel) trusts. On Python 3.9, or without truststore, it trusts the store as Python's ssl.create_default_context() loads it: on Windows only the roots already present in the ROOT and CA stores, on macOS the Python build's OpenSSL file (/etc/ssl/cert.pem for Apple's Python) and not the keychain. REQUESTS_CA_BUNDLE or CURL_CA_BUNDLE, when set, is trusted instead, checked by OpenSSL. certifi is never used Windows fetches a missing root from Windows Update the first time its own verifier meets it; OpenSSL reads the store and cannot. macOS keeps added CAs in the keychain, which OpenSSL has no reader for. truststore hands the check to those verifiers but needs Python 3.10, and it is installed only on the sessions hostctl builds, never into ssl for the whole process. The variables keep their meaning from requests so that a setup relying on them keeps working. Each library builds its own requests session, so the trust is installed on the adapter it mounts, which needs requests 2.32.3 or later (earlier 2.32 releases replace an adapter's context with certifi's) and urllib3 2 (1.26 passes no server name for an IP-address host, so the verifying context refuses it). A bundle given as verify= replaces the store and the variables on every leg.
Native WinRM verify=<CA bundle> raises ValueError (at construction with provider="native", when the leg is built for "auto") PowerShell remoting checks the server certificate against the Windows certificate store; New-PSSessionOption can skip the CA, name and revocation checks but has no parameter naming a trust source. Import the CA into the store, or use pywinrm or PSRP.
SSH verify a bool only (a string raises TypeError); a known_hosts the caller names is checked even under verify=False Host keys are not certificates, so there is no CA bundle to name; trusted keys are known_hosts. Naming them is a request to check, so the stricter of the two settings wins rather than one silently switching the other off.
SSH known_hosts=None raises ValueError naming verify=False asyncssh reads it as "no host-key check"; verify=False is the config's spelling of that. The ssh_config (the user's own ~/.ssh/config by default) is the caller's own configuration and is honoured as written, so its UserKnownHostsFile none also switches the check off.
SSH ssh_config the user's ~/.ssh/config by default (USER_SSH_CONFIG, resolved when a connection opens), as ssh reads it; None reads none; a str is configuration text, not a file name; never a URI key Reading the user's configuration is what ssh does, so an alias, a key, a jump host or a known-hosts file that works there works here; a service or test that must not depend on a personal file says None. A str is text because asyncssh skips a keyword it does not know, so a guess between a name and a one-line configuration could connect with no configuration and say nothing; a file is pathlib.Path(...). A URI cannot name one because a ProxyCommand starts a local process.
SSH ssh_config text written to a private temporary file for the length of one connect asyncssh reads a client configuration only from a file it opens itself; the file is readable by this user only and removed as soon as the connection is open or has failed.
SSH port=None, username=None left to the ssh_config's Port/User, else 22 and default_username or the local account; a value passed wins As ssh without -p/-l. Always passing 22 would send an alias whose configuration names another port to port 22 of its HostName.
Container verify other than True raises ValueError unless hostctl builds the client for an https:// engine_url Other engine URLs are plain HTTP or local sockets; without one, docker.from_env takes TLS from DOCKER_TLS_VERIFY/DOCKER_CERT_PATH; a client_factory builds a client hostctl does not configure.
Container verify=False urllib3 warns InsecureRequestWarning on each request; the WinRM legs do not pywinrm and pypsrp silence that warning process-wide when told not to verify. hostctl does not change process-wide warning filters on their behalf.