Metadata-Version: 2.4
Name: ssh-mcp-vn
Version: 0.6.3
Summary: SSH and SFTP MCP server with durable Windows jobs and Linux synchronous tools
Author-email: NamHT <namht.dev@gmail.com>
License-Expression: MIT
Requires-Python: >=3.12
Description-Content-Type: text/markdown
Requires-Dist: mcp<2,>=1.2.0
Requires-Dist: asyncssh>=2.14.0
Requires-Dist: pydantic>=2.8.0
Requires-Dist: pydantic-settings>=2.4.0
Requires-Dist: python-dotenv>=1.0.0
Provides-Extra: runner-windows
Requires-Dist: pywin32>=306; sys_platform == "win32" and extra == "runner-windows"
Provides-Extra: dev
Requires-Dist: pytest>=8.0.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
Requires-Dist: ruff>=0.5.0; extra == "dev"
Requires-Dist: mypy>=1.10.0; extra == "dev"

# SSH & SFTP MCP Server (ssh-mcp-vn)

MCP server cho SSH/SFTP nhiều máy chủ, có durable job qua nProx Runner trên Windows/Linux và
Windows agentless đáp ứng điều kiện detach. Bản `0.6.3` đã được phát hành trên PyPI.

## Current support matrix (0.6.3)

| Capability | Windows | Linux |
|---|---|---|
| Durable jobs | Fresh compatible service preferred; safe complete-breakaway agentless fallback when permitted | Service required; otherwise use legacy synchronous SSH explicitly, not automatic durable fallback |
| PTY | Service-only native ConPTY on Windows 10 1809 / Server 2019+ | Service-only delegated-cgroup terminal implementation; native Linux acceptance still unverified |
| Stdin / resize | Service stdin and capability-gated ConPTY resize | Service stdin and capability-gated PTY resize |
| Hard log quota / immutable artifacts | Service only; agentless has neither guarantee | Service only; legacy synchronous execution has neither guarantee |
| Verified transfers | Alias-only scoped SFTP, independent hash readback | Same; local paths always refer to the MCP host staging root |
| Health | Read-only marker facts and independent checks; no executable/version command | Same |

`runner=auto` prefers the service; `required` refuses unavailable service; `disabled` never chooses
it. A submitted job retains its owning mode: service loss never triggers agentless replay. See
[installation and rollback](#runner-installation-and-rollback) for private ACLs,
absolute executables/spool, clean PATH, Windows SCM and systemd `Delegate=yes`/cgroup-v2 requirements.
These are operator instructions, not evidence of a live install. SCM/systemd deployment, SSH
disconnect/reconnect on real hosts, restart/reboot, native Linux containment/PTY, verified SFTP on
live Windows/Linux servers and ChatGPT connector acceptance remain external gates. The MCP package
and Hub upstream use 0.6.3; runner services on target hosts must still be installed separately.
Runner protocol 1 remains compatible with an installed 0.6.0 service.

## Verified file transfers (0.6.0)

`sftp_upload` and `sftp_download` use configured aliases only, with no inline credentials,
file content or Base64. Both return `{server_name,source_path,destination_path,bytes_transferred,
sha256,verification}`; verification is always `sha256`, even without an expected digest.

| Input | Contract |
|---|---|
| `local_path` | Required nonempty POSIX-relative file path inside the **MCP host's** `SFTP_STAGING_ROOT`, not the caller device; upload source or download destination. Example: `release/app.zip`. |
| `remote_path` | Required absolute file path using forward slashes, inside the alias's transfer roots. Examples: `/srv/releases/app.zip`, `C:/releases/app.zip`. |
| `server_name` | Optional configured alias; default null selects the only configured server. Required with multiple aliases. Example: `build`. |
| `expected_sha256` | Optional 64-hex-digit source digest; default null. Recommended for downloaded job artifacts. Example: `ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad`. |
| `overwrite` | Optional boolean, default false (atomic no-clobber). True permits atomic replacement of an existing regular file. |

Configure an absolute existing `SFTP_STAGING_ROOT` on the MCP host and optional per-alias
`allowed_transfer_roots`, for example `["/srv/releases", "/srv/runner/jobs"]` or
`["C:/releases", "C:/nprox/jobs"]`. An empty list allows only canonical remote login home and its
descendants, not the whole filesystem. Parents must already exist: transfers do not create directories.
Paths reject traversal, symlinks, junction/reparse traversal, devices and alternate data streams.
Keep these roots private to trusted transfer/service accounts; SFTP metadata does not replace
filesystem ACLs or protection against another privileged writer. Legacy file tools retain their
compatibility behavior and do not acquire the new remote-root policy.

`SFTP_MAX_FILE_SIZE` is a positive byte limit, default 2147483648 (2 GiB), checked before and during
streaming and independent temporary-file readback. Source bytes are incrementally hashed; a securely
named exclusive sibling temporary file is independently SHA-256 checked before atomic publication.
Remote overwrite requires the server's POSIX-rename extension; there is no delete-and-rename fallback.
Local no-clobber uses a same-directory hard link and requires filesystem support. Neither operation
replays automatically. On uncertain disconnect/publication, inspect the destination and named
`.part-*` sibling before deciding what to do; temporary evidence is deliberately retained. Only
definite pre-publication failures clean their validated temporary file. Source files remain untouched.

Example upload:

```json
{"local_path":"release/app.zip","remote_path":"/srv/releases/app.zip","server_name":"build","overwrite":false}
```

For a binary or complete job log, obtain `get_job_artifact`, then pass its path and digest:

```json
{"local_path":"logs/stdout.log","remote_path":"/srv/runner/jobs/job-0123456789abcdef0123456789abcdef/stdout.log","server_name":"build","expected_sha256":"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"}
```

Call `sftp_download` with this shape (replace the sample digest with the actual artifact digest).
Create `logs` inside staging first. File bytes never enter MCP JSON. Existing `sftp_upload_file`,
`sftp_download_file` and `sftp_upload_staged_file` remain available; staged-upload SHA-256 verification
now uses SFTP readback instead of a remote `sha256sum` command.

## Durable job API (0.6.3)

Capability order: nProx Runner tương thích/healthy → Windows agentless với complete Job Object
breakaway. Linux agentless bị từ chối vì descendant có thể thoát process group; Linux không có
runner vẫn gọi `ssh_execute_command` trực tiếp. `start_job`/`execute_script` không fallback ngầm
sang lệnh đồng bộ. Native remote service/reboot acceptance chưa được xác minh; xem hướng dẫn
[runner operations](#runner-installation-and-rollback) trước khi triển khai.
Windows có ancestor Job Object không cho breakaway cũng trả capability error có hướng xử lý.

Job tools chỉ nhận đúng configured server name, không nhận host/user/password/key. Display name có
thể chứa khoảng trắng/Unicode; chỉ key của MCP server trên Hub mới bắt buộc là slug. Giữ tên target
và spool ổn định để tìm lại job sau restart.
Ví dụ policy (host minh họa):

```json
{
  "name": "windows-build", "host": "windows.example.invalid", "user": "ops",
  "key_path": "C:/keys/ops_ed25519", "platform": "windows",
  "job_spool": "C:/nprox/jobs", "allowed_shells": ["powershell", "pwsh", "cmd"],
  "allowed_cwd_roots": ["C:/build workspace"],
  "executables": {"pwsh": "C:/Program Files/PowerShell/7/pwsh.exe"}, "runner": "auto"
}
```

Policy fields: `platform` defaults to `auto` (fixed read-only OS probes), `job_spool` defaults to
`.nprox-ssh/jobs`, `allowed_shells=null` selects the resolved OS defaults, `allowed_cwd_roots=[]`
allows only canonical remote login home and its subdirectories. Explicit roots replace that default;
include the home path explicitly to retain access to it. `executables={}` uses platform defaults. Executable overrides
must be absolute paths. `runner=auto|disabled|required` defaults to `auto`; `required` needs a fresh
compatible exact-tree service marker. Use a dedicated account and private
spool ACLs; do not let untrusted users modify request/script/state/launcher files.

### Input contracts

`start_job` and `execute_script` have the same flat inputs. Types are strict; unknown fields fail.

| Field | Contract and directly usable example |
| --- | --- |
| `shell` | Required enum powershell/pwsh/cmd/bash/sh subject to alias policy; `"powershell"`. Linux durable execution requires the runner service. |
| `script` | Required UTF-8 source, 1–1,000,000 characters; `"Write-Output 'Việt Nam'"`. Multiline scripts are uploaded as files. |
| `idempotency_key` | Required opaque string, 1–200 characters; `"build-20260914-1"`. Same alias/key plus identical normalized request reuses; different content conflicts. |
| `server_name` | Optional configured slug; null selects the only alias, required with multiple aliases; `"windows-build"`. |
| `cwd` | Optional absolute remote directory; default null uses canonical login home even when spool is elsewhere. Empty roots restrict cwd to home and its subdirectories. Required when roots are configured; `"C:/build workspace"`. Symlinks/reparse traversal are rejected before publication and checked again by the child. Windows SFTP `/C:/...` paths are converted to native cwd. |
| `env` | Optional flat string map; null means `{}`; `{"CI":"true"}`. Names match `[A-Za-z_][A-Za-z0-9_]*` and must be unique ignoring case on Windows. Values cannot contain NUL; Unicode/newlines are preserved. Encoded JSON ≤64 KiB. |
| `command_timeout` | Integer seconds 1–86400, default 120; `900`. Supervisor stops the owned process tree. |
| `idle_timeout` | Integer seconds 1–86400, default null disables no-output timeout; `300`. |
| `pty` | Boolean default false; `true` requires a fresh service marker with `pty=true`, never agentless/legacy fallback. Initial terminal size 80x24; merged stdout/stderr appear in stdout. Windows uses native ConPTY; Linux uses a delegated cgroup terminal. |

`job_status` and `cancel_job` require `job_id` matching `job-` plus 32 lowercase hex digits,
for example `job-0123456789abcdef0123456789abcdef`; optional `server_name` follows the same rule.
`job_logs` additionally accepts independent `stdout_offset` and `stderr_offset` integer byte
positions ≥0 (both default 0; examples 4096 and 512) and per-stream `limit` 1–262144 bytes (default
65536; example 32768). Resume using returned next offsets to preserve complete UTF-8 codepoints.
`job_input` adds required UTF-8 `text` 1–1,000,000 characters (example `"yes\n"`) and integer
`sequence` from 1 through 10^20−1 (example 1). It requires service ownership and a freshly verified
compatible marker with stdin=true (and pty=true for PTY jobs); agentless jobs reject input without storing it.
For canonical PTYs, send `"\u0003"` for Ctrl-C or `"\u0004"` on an empty line for Ctrl-D/EOF;
raw-mode applications interpret these bytes themselves. Ordinary piped stdin has no public EOF flag.
`job_resize` takes required `job_id`, `columns` and `rows` (integers 1–1000), and `sequence`
(integer 1 through 10^20−1, starting at 1 independently of stdin), plus optional `server_name`.
Example: `{"job_id":"job-0123456789abcdef0123456789abcdef","columns":100,"rows":30,"sequence":1}`.
It accepts only nonterminal service PTY jobs with a fresh pty=true marker. Retry identical dimensions
only; gaps wait for earlier sequences. A returned status confirms durable publication, not application;
the worker records the last applied sequence/dimensions in `pty-state.json`. Failed acknowledgement
interrupts the job, never replays a possibly applied resize.

```json
{"shell":"powershell","script":"Write-Output 'Việt Nam'; [Console]::Error.WriteLine('warning'); exit 0","idempotency_key":"build-20260914-1","server_name":"windows-build","cwd":"C:/build workspace"}
```

Call `start_job` with this object for background execution, or `execute_script` to wait. Then call
`job_status` with the returned `job_id`; read `job_logs` repeatedly with returned next offsets.
Call `cancel_job` with that same job ID to stop its identity-checked owning tree. Cancelling an
already terminal job returns its current status. Cancellation is never performed by process name.

### Result contracts and recovery

- `start_job`: `{job_id, state, mode, reused}`. Mode is `service` or `agentless`; there are no submission
  timestamps or negotiated-capability fields in this release. `queued` is not proof of launch.
- `job_status`/`cancel_job`: `{job_id,state,mode,exit_code,started_at,finished_at,stdout_size,
  stderr_size,pid,pgid,process_identity,error_code}`. Unknown exit/timing/identity fields are null; timestamps
  are UTC ISO-8601, sizes are bytes, and process identity is an opaque OS creation value.
  Public runner error_code is null or one of RUNNER_INTERRUPTED, RUNNER_INVALID_JOB,
  RUNNER_PUBLICATION_FAILED and RUNNER_LOG_QUOTA_EXCEEDED; arbitrary stored diagnostics are not exposed.
- `job_logs`: `{stdout,stderr,next_stdout_offset,next_stderr_offset,stdout_eof,stderr_eof}`.
  EOF is a snapshot, not completion; a running job can append later. Pagination is unchanged.
- `job_log_preview(job_id,limit=65536,server_name=null)` returns `{stdout,stderr,stdout_size,
  stderr_size,truncated}`. Limit is **aggregate emitted TextContent.text JSON UTF-8 bytes**, including
  indentation, metadata, escaping and omission markers; integer 256–262144. MCP framing and the
  duplicate structuredContent object are excluded. Both streams initially
  share equally, then unused space goes to stdout before stderr. Heads/tails never overlap or split
  UTF-8 characters. Invalid UTF-8 is an actionable error, never replacement text. This is a snapshot,
  not a resumable page; use `job_logs` for complete sequential text.
- `get_job_artifact(job_id,stream,server_name=null)`, with stream `stdout` or `stderr`, returns
  `{remote_path,size,sha256}` only for terminal service logs after all output pumps close. SHA-256
  covers exactly the retained immutable bytes, including binary logs. No artifact bytes enter MCP
  JSON. Metadata is atomically cached in `result.json`; proven stopped-owner recovery can backfill
  older completed service logs. Agentless artifacts are unsupported; use legacy log access.
  Download with `sftp_download`, passing this `remote_path` and `sha256` as `expected_sha256`;
  the artifact directory must be within the alias's `allowed_transfer_roots`.
- `execute_script`: `{exit_code,timed_out,duration_ms,stdout,stderr,truncated,job_id,error_code}`. It polls
  every 250 ms for at most `command_timeout + 30` seconds after submission, including reconnect
  and collection time. Each stream is capped at 262144 bytes; use `job_logs` for the remainder.

States are `queued`, `running`, `succeeded`, `failed`, `timed_out`, `cancelled`, `interrupted`.
Native exit 0 succeeds even with warning stderr unless a runner error code is present; nonzero exit is structured failure, not an MCP
protocol error. Null native exit can occur when execution never started or was interrupted.
Definite Windows detach exit 78 is recorded separately in immutable `launch-refusal.json` as
`AGENTLESS_DETACH_UNSUPPORTED`; status is failed with null exit/start and no output. Repeat submission
returns the recorded capability guidance. A transport loss never invents a refusal or native result.

SSH connect timeout uses `SSH_CONNECT_TIMEOUT` (default 10 seconds); keepalive is 15 seconds with
three unanswered probes. Read-only status/log/result/refusal recovery and OS probes use at most
four attempts, sleeping 0.25/0.5/1 second plus 0–0.1-second jitter between failures. Authentication,
permission and invalid-data errors are not retried. The execution wait deadline still applies.
Writes, launch, input and cancel are not generically retried.

After ambiguous submission, reconnect inspects the deterministic job directory and request hash.
An existing submission may be returned as reused; **it is never launched a second time**. An
ambiguous launch remains an actionable tool error after inspection. If publication left `queued`
without a launched supervisor, it can remain queued: inspect the spool and resolve operationally,
never change keys or delete identity data merely to retry an uncertain write. Missing data after
reconnect is not an instruction to replay automatically. Keep finished identity records for as long
as keys can be retried; deleting the spool removes that protection. Service startup recovers only
the prior owner proven stopped by its OS-released exclusive spool lock, never heartbeat age alone.

Windows scripts use UTF-8 BOM (cmd receives a separate BOM-free `.cmd` file). The supervisor starts
children suspended, assigns a private Job Object, retains ownership after the leader exits and
drains the tree on timeout/cancel. Environment starts from required Windows variables and a sanitized
PATH excluding OpenAI/Codex/bin; configured executable overrides use absolute paths. Script and env
are retained in the remote spool: do not pass secrets unless that storage is approved.

Runner-service captures enforce `SSH_RUNNER_MAX_JOB_LOG_BYTES` from the **service host environment**:
default 1073741824 (1 GiB), decimal integer 0–9223372036854775807. This is a hard retained aggregate
stdout+stderr byte quota per job (PTY output is merged stdout), not a filesystem or temporary-file
quota. Owned concurrent pumps retain no more than that budget, hash retained bytes incrementally,
record quota exhaustion, cancel the exact tree once and drain/close remaining pipes. Zero permits
empty output only; exactly filling the budget is allowed until another byte arrives. Exhaustion
publishes `state=failed`, `error_code=RUNNER_LOG_QUOTA_EXCEEDED`, `truncated=true`, preserving any
native exit including zero. Quota failure takes precedence over concurrent cancellation/timeout.
Inspect retained artifacts and increase the service quota before submitting a genuinely new job;
do not change an existing key merely to replay uncertain work. A quota can cut binary or UTF-8 bytes;
metadata still works, while text reads reject incomplete/invalid UTF-8. Direct/agentless execution
retains legacy behavior **without this service hard quota**. There is no rotation or automatic
cleanup; operators must still provision disk capacity, filesystem quotas and retention. Keep spool
logs immutable after completion; external modifications invalidate cached artifact evidence.

Windows execution normalizes the effective `PATHEXT` after applying `env` overrides: `.COM`, `.EXE`,
`.BAT`, and `.CMD` are always present. Additional extensions matching `\.[A-Za-z0-9]+` are preserved;
entries are trimmed, uppercased, and deduplicated, and empty/malformed entries are discarded. This
also applies to empty or mixed-case `PathExt` overrides, so absolute native commands remain executable.

Cmd's UTF-8 label offsets can silently skip commands. Non-ASCII cmd scripts therefore reject label
definitions, `GOTO`, `CALL :label`, and dynamic `CALL` targets containing `%`/`!` before submission
or launch. This is conservative lexical validation (including escaped tokens), not a batch parser;
comments/text resembling these constructs may also be refused. Use PowerShell/pwsh or an ASCII-only
batch script for label/subroutine flow. Plain Unicode filenames and static `CALL other.cmd` remain
supported. Derived `.cmd` files use CRLF and initialize UTF-8 through the quoted absolute
`%SystemRoot%\System32\chcp.com`, independent of PATH; initialization failure exits 78 before user
content. Windows `SystemRoot` is protected from request `env` overrides. Stored source/hashes do not change.

The existing SSH connector currently sets `known_hosts=None`, so it does not verify host keys.
Deploy only within a trusted connection boundary; this release does not change that legacy setting.
Legacy `ssh_execute_command` is deprecated for supported durable workflows but retained through at
least this release window. Its raw-shell quoting, synchronous lifetime and text output remain.

### Acceptance and evaluations

The legacy agentless integration module contacts SSH only if `SSH_JOB_TEST_CONFIG` is set. It must point to a
JSON object with `disposable:true` and `windows`/`linux` entries, each containing `server` (the config
shape above with explicit platform and allowed cwd roots) and `cwd` (an existing path with spaces).
Run `pytest tests/integration/test_remote_jobs.py -v` only against disposable hosts; the Windows
durability case takes at least ten minutes. That module tests the no-runner contract; use separate
fixtures from the runner acceptance module below. Linux quoting is checked through legacy execution. Local Git disconnect acceptance pushes
only to a temporary local bare repository and verifies one pre-receive hook invocation.

`evaluations/ssh_jobs_read_only.xml` contains ten independent status/log questions over the fixed
`job_read_only_fixture.json` dataset. `tests/test_job_evaluation.py` verifies the answers through
actual MCP tool calls against in-memory SFTP, including 4-byte UTF-8 pagination; no remote fixture
is provisioned. For an LLM evaluation, provision these immutable fixture states/logs under a dedicated
`evaluation` alias/spool beforehand and allow only `job_status`/`job_logs`. Job IDs are given in the
fixture. The answer verification test is not an LLM quality score.

## Hướng dẫn cài đặt

### Chạy qua UVX (Được khuyến nghị)
Bạn có thể chạy trực tiếp server này qua `uvx` mà không cần phải tải hay cài đặt thủ công:

```bash
uvx ssh-mcp-vn==0.6.3
```

## Các tính năng chính

- **Hỗ trợ Multi-host**: Tương tác với nhiều máy chủ Windows/Linux mà không cần chạy nhiều instance.
- **Remote execution runner**: Ưu tiên service trên máy đích; Windows có agentless fallback an toàn,
  Linux không có runner vẫn dùng tool đồng bộ legacy một cách tường minh.
- **Durable jobs**: `start_job`, status/log/input/resize/cancel, idempotency và reconnect không chạy
  lại thao tác chưa xác định kết quả.
- **Thực thi qua SSH**: Chạy lệnh bash/shell tùy ý trên máy chủ từ xa.
- **Quản lý thư mục**: Dễ dàng tạo cấu trúc thư mục từ xa (hoạt động giống lệnh `mkdir -p`).
- **Tải lên qua SFTP**: Đẩy file local của MCP process hoặc file staging trên MCP Hub lên máy chủ
  từ xa.
- **Tải về qua SFTP**: Tải file từ máy chủ từ xa về máy tính.
- **Liệt kê file bằng SFTP**: Xem danh sách các file trong một thư mục bất kỳ trên máy chủ.

## Cấu hình (Khuyên dùng qua File JSON)

Vì mảng cấu hình có thể khá dài và việc viết chuỗi JSON dính liền với các dấu `\` (escape) trong cấu hình môi trường sẽ phức tạp, bạn nên tách cấu hình ra một file JSON riêng biệt.

**Bước 1:** Tạo một file có tên `ssh_servers.json` ở bất kỳ đâu trên máy của bạn (ví dụ `C:\Users\BiNgo\.codex\ssh_servers.json`) và điền danh sách các server dạng mảng thuần túy.

Ưu tiên dùng SSH key qua `key_path` và user vận hành riêng có sudo. Không nên để `root` + `password` plaintext làm cấu hình mặc định.

```json
[
  {
    "name": "toppion-linux",
    "host": "192.168.1.100",
    "user": "ops",
    "key_path": "C:\\Users\\BiNgo\\.ssh\\toppion_ops_ed25519",
    "port": 22
  },
  {
    "name": "toppion-registry",
    "host": "192.168.1.101",
    "user": "ops",
    "key_path": "C:\\Users\\BiNgo\\.ssh\\toppion_ops_ed25519"
  },
  {
    "name": "toppion-master",
    "host": "192.168.1.102",
    "user": "ops",
    "key_path": "C:\\Users\\BiNgo\\.ssh\\toppion_ops_ed25519"
  }
]
```

**Bước 2:** Cập nhật `mcp_config.json` của ArcticFactory chỉ truyền vào biến `SSH_CONFIG_FILE` trỏ đến file đó:

```json
{
  "mcpServers": {
    "ssh-server": {
      "command": "uvx",
      "args": [
        "ssh-mcp-vn==0.6.3"
      ],
      "env": {
        "SSH_CONFIG_FILE": "C:\\Users\\NamHT\\ssh_servers.json"
      }
    }
  }
}
```

### Lấy danh sách server từ NProx API

Khi inventory được quản lý tập trung, truyền `--url-host` và đặt token trong biến môi trường
`SSH_URL_HOST_TOKEN`. URL phải là HTTPS; token không được đưa vào `args`, log hoặc output tool.

```json
{
  "mcpServers": {
    "ssh-server": {
      "command": "uvx",
      "args": [
        "ssh-mcp-vn==0.6.3",
        "--url-host",
        "https://nprox.dev/api/external/devops/servers"
      ],
      "env": {
        "SSH_URL_HOST_TOKEN": "<NPROX_API_TOKEN>",
        "SSH_REMOTE_POLICY": "{\"windows-build\":{\"platform\":\"windows\",\"job_spool\":\".nprox-ssh/jobs\",\"allowed_cwd_roots\":[\"C:/nprox/work\"],\"runner\":\"required\"}}"
      }
    }
  }
}
```

Endpoint phải trả về object có mảng `servers`; mỗi item cần `name`, `host`, `port` và `username`.
Nếu item không có `password`/`key_path`, MCP gọi endpoint kế bên
`/connection?name=<URL-encoded-name>` bằng cùng Bearer token và chỉ giữ credential trong process;
credential không xuất hiện trong tool schema, log hoặc kết quả.

`SSH_REMOTE_POLICY` là object JSON theo tên server, chỉ cho phép các field không chứa secret:
`platform`, `job_spool`, `allowed_shells`, `allowed_cwd_roots`, `allowed_transfer_roots`,
`executables`, `runner`. Nó không thể thay `host`, `user`, port hoặc credential. Policy của MCP và
tham số cài Runner phải dùng cùng `job_spool` và `allowed_cwd_roots`.

### Thêm server KHÔNG dùng file JSON (nhập thẳng)

Nếu chỉ có 1 server và không muốn tạo file cấu hình hay escape chuỗi JSON, bạn có thể khai báo **từng biến env riêng** trong `mcp_config.json`:

**Dạng 1 — Một server (từng biến env riêng, khuyên dùng):**

```json
{
  "mcpServers": {
    "ssh-server": {
      "command": "uvx",
      "args": [
        "ssh-mcp-vn==0.6.3"
      ],
      "env": {
        "SSH_NAME": "production",
        "SSH_HOST": "192.168.1.10",
        "SSH_USER": "ops",
        "SSH_PORT": "22",
        "SSH_KEY_PATH": "~/.ssh/toppion_ops_ed25519"
      }
    }
  }
}
```

Các biến hỗ trợ: `SSH_NAME` (bắt buộc), `SSH_HOST` (bắt buộc), `SSH_USER`, `SSH_PORT` (mặc định 22), `SSH_KEY_PATH`, `SSH_PASSWORD`.

**Dạng 2 — Nhiều server (mảng JSON):**

```json
{
  "mcpServers": {
    "ssh-server": {
      "command": "uvx",
      "args": [
        "ssh-mcp-vn==0.6.3"
      ],
      "env": {
        "SSH_SERVERS": "[{\"name\": \"production\", \"host\": \"192.168.1.10\", \"user\": \"ops\", \"key_path\": \"~/.ssh/toppion_ops_ed25519\"}, {\"name\": \"staging\", \"host\": \"192.168.1.11\", \"user\": \"ops\", \"key_path\": \"~/.ssh/toppion_ops_ed25519\"}]"
      }
    }
  }
}
```

> Thứ tự ưu tiên: `SSH_URL_HOST` > `SSH_CONFIG_FILE` > `SSH_SERVERS` > các biến `SSH_NAME`/`SSH_HOST` riêng lẻ. Nếu chỉ có 1 server, dùng Dạng 1 cho gọn, không cần escape JSON.

Hoặc chạy trực tiếp qua CLI với tham số `--servers` (dùng để test nhanh, hỗ trợ cả object đơn lẫn array):

```bash
uvx ssh-mcp-vn==0.6.3 --servers '{"name": "production", "host": "192.168.1.10", "user": "ops", "key_path": "~/.ssh/toppion_ops_ed25519"}' --smoke-test production
```

### Các trường trong từng block JSON cấu hình
Mỗi object của một server hỗ trợ các trường sau:
- `name` (bắt buộc): Tên gọi riêng cho server này (VD: "production", "staging") để giúp công cụ AI nhận biết server muốn thao tác.
- `host` (bắt buộc): Địa chỉ IP hoặc Hostname của máy chủ SSH.
- `user` (bắt buộc): Tên đăng nhập (username).
- `port` (tùy chọn): Cổng SSH, mặc định là 22.
- `key_path` (khuyên dùng): Đường dẫn tuyệt đối đến file khóa bí mật (private key).
- `password` (tùy chọn, không khuyên dùng): Mật khẩu đăng nhập. Chỉ nên dùng tạm khi chưa chuyển xong sang SSH key.

### Kiểm tra cấu hình và smoke test

Sau khi sửa `ssh_servers.json`, hãy restart/reload Codex MCP trước khi kết luận lỗi config vì MCP server đọc cấu hình khi process khởi động.

Kiểm tra config đã được đọc đúng và không in secret:

```bash
uvx ssh-mcp-vn==0.6.3 --config-file "C:\Users\BiNgo\.codex\ssh_servers.json" --validate-config
```

Chạy smoke test chuẩn cho toàn bộ server:

```bash
uvx ssh-mcp-vn==0.6.3 --config-file "C:\Users\BiNgo\.codex\ssh_servers.json" --smoke-test all
```

Smoke test dùng lệnh:

```bash
hostname && whoami && pwd && uptime
```

Nếu đang làm trực tiếp trong source repo, có thể dùng script:

```bash
python3 uvx/ssh/scripts/smoke_test.py --config-file "C:\Users\BiNgo\.codex\ssh_servers.json" --server all
```

Checklist vận hành ngắn:

1. Dùng alias MCP thống nhất: `toppion-linux`, `toppion-registry`, `toppion-master`.
2. Không phụ thuộc shell alias; MCP chỉ dùng `name` trong `ssh_servers.json`.
3. Ưu tiên `key_path`; hạn chế `password` plaintext.
4. Dùng user vận hành riêng có sudo; chỉ dùng `root` khi thật cần.
5. Sửa config xong thì restart/reload Codex MCP rồi chạy smoke test.

## Upload file từ Windows/macOS qua MCP Hub Linux

Khi MCP chạy trên server B, `local_path` là đường dẫn trên B, không phải đường dẫn Windows/macOS.
Với file tới 1 GB, dùng hai chặng SFTP; không truyền base64 qua MCP:

```text
Windows/macOS --OpenSSH SFTP--> /srv/ssh-mcp/inbox trên B --MCP SFTP--> server A
```

### Cấu hình staging trên B

Tạo user chỉ dùng SFTP và một group mà service account chạy MCP có quyền đọc. Ví dụ dưới đây dùng
`mcp-upload` làm user nhận file và `ssh-mcp-readers` làm group đọc:

```bash
sudo groupadd --system ssh-mcp-readers
sudo useradd --system --home-dir /inbox --shell /usr/sbin/nologin mcp-upload
sudo usermod -aG ssh-mcp-readers <mcp-service-user>
sudo mkdir -p /srv/ssh-mcp/inbox
sudo chown root:root /srv/ssh-mcp
sudo chmod 0755 /srv/ssh-mcp
sudo chown mcp-upload:ssh-mcp-readers /srv/ssh-mcp/inbox
sudo chmod 2750 /srv/ssh-mcp/inbox
```

Thay `<mcp-service-user>` bằng user Linux thực sự chạy MCP Hub. Cấu hình public key của
`mcp-upload` bằng cơ chế `AuthorizedKeysFile` của hệ điều hành; không copy private key lên B.

Thêm block tương đương vào `sshd_config`, kiểm tra bằng `sudo sshd -t`, rồi reload SSH:

```text
Match User mcp-upload
    ChrootDirectory /srv/ssh-mcp
    ForceCommand internal-sftp -d /inbox
    PasswordAuthentication no
    PubkeyAuthentication yes
    AllowTcpForwarding no
    X11Forwarding no
    PermitTunnel no
```

Cấu hình process MCP trên B:

```text
SFTP_STAGING_ROOT=/srv/ssh-mcp/inbox
SFTP_MAX_FILE_SIZE=2147483648
```

Đặt quota/cảnh báo dung lượng cho B. Dùng `systemd-tmpfiles` hoặc cleanup job của hệ điều hành để
xóa file staging và file `.uploading` quá 24 giờ.

### Windows 10/11 PowerShell

Kiểm tra OpenSSH Client; chỉ cần chạy lệnh cài đặt bằng PowerShell Administrator nếu trạng thái là
`NotPresent`:

```powershell
Get-WindowsCapability -Online | Where-Object Name -like 'OpenSSH.Client*'
Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0
sftp -i "$env:USERPROFILE\.ssh\id_ed25519" mcp-upload@server-b
```

Trong prompt `sftp`, upload vào tên `.uploading`; `reput` tiếp tục file dở nếu mạng đứt. Khi hoàn
tất mới rename thành tên ready:

```text
sftp> mkdir release
sftp> reput "C:/Users/Nam/Downloads/app.tar.gz" release/app.tar.gz.uploading
sftp> rename release/app.tar.gz.uploading release/app.tar.gz
```

Lấy SHA-256 trên Windows nếu cần xác minh end-to-end:

```powershell
(Get-FileHash -Algorithm SHA256 "C:\Users\Nam\Downloads\app.tar.gz").Hash.ToLower()
```

### macOS

```bash
sftp -i "$HOME/.ssh/id_ed25519" mcp-upload@server-b
```

```text
sftp> mkdir release
sftp> reput "/Users/nam/Downloads/app.tar.gz" release/app.tar.gz.uploading
sftp> rename release/app.tar.gz.uploading release/app.tar.gz
```

```bash
shasum -a 256 "/Users/nam/Downloads/app.tar.gz"
```

`mkdir release` chỉ cần chạy lần đầu. Windows và macOS đều dùng dấu `/` cho đường dẫn remote trên
B. Không truyền đường dẫn `C:/Users/...` hoặc `/Users/...` vào MCP.

### Relay file staging từ B sang A

Tool call giống nhau trên Windows và macOS:

```text
sftp_upload_staged_file
  staged_path: release/app.tar.gz
  remote_path: /opt/releases/app.tar.gz
  server_name: server-a
  expected_sha256: <64-hex-digits>  # tùy chọn
  overwrite: false
```

Tool chỉ nhận đường dẫn tương đối bên trong `SFTP_STAGING_ROOT`, từ chối symlink và file còn đuôi
`.uploading`. File được upload lên A bằng tên `.part-*`, kiểm tra kích thước/SHA-256 rồi mới rename
sang tên thật. Mặc định không ghi đè file đã tồn tại và luôn giữ file staging để retry/cleanup sau.

### Fixed disposable evaluation fixture

Mười câu hỏi read-only trong `evaluations/ssh_staged_relay_read_only.xml` dùng đúng ba file text
dưới đây trên một target dùng riêng cho evaluation. MCP không tự tạo fixture này; không đặt nó lên
production.

| File | Nội dung cố định |
| --- | --- |
| `/srv/ssh-mcp-evaluation/manifests/windows.txt` | `platform=windows`, `version=3.0.0`, `arch=x64`, `artifact=agent-3.0.0-win-x64.zip`, `bytes=1048576`, `sha256=` + 64 ký tự `1` |
| `/srv/ssh-mcp-evaluation/manifests/macos.txt` | `platform=macos`, `version=3.0.0`, `arch=arm64`, `artifact=agent-3.0.0-macos-arm64.tar.gz`, `bytes=524288`, `sha256=` + 64 ký tự `2` |
| `/srv/ssh-mcp-evaluation/incoming/status.txt` | `temporary=.app-3.1.0.tar.gz.part-deadbeef`, `bytes=262144`, `state=incomplete` |

Evaluation chỉ dùng `sftp_list_directory` và các lệnh đọc như `find`, `stat`, `cat`; không upload,
rename, xóa hoặc chmod.

## Danh sách công cụ (Tools)

- `start_job`, `execute_script`: Chạy script UTF-8 với `shell`, `cwd`, `env`, timeout và
  `idempotency_key`; không nhét PowerShell vào raw command string.
- `job_status`, `job_logs`, `job_log_preview`, `get_job_artifact`: Theo dõi job và đọc output lớn
  theo offset hoặc artifact SHA-256.
- `job_input`, `job_resize`, `cancel_job`: Điều khiển stdin/PTY và dừng đúng process tree của job.
- `runner_health`: Kiểm tra SSH, runner, account, disk và executable paths mà không chạy tool.
- `sftp_upload`, `sftp_download`: Truyền file có phạm vi cho phép và xác minh SHA-256.
- `ssh_execute_command`: Chạy một lệnh shell trên máy Linux từ xa.
- `ssh_create_folder`: Tạo thư mục từ xa.
- `sftp_upload_file`: Chuyển một file từ local lên máy chủ.
- `sftp_upload_staged_file`: Chuyển an toàn một file ready trong staging root của MCP Hub lên máy
  chủ, hỗ trợ kiểm tra SHA-256 và atomic rename.
- `sftp_download_file`: Lấy một file từ máy chủ về local.
- `sftp_list_directory`: Liệt kê tất cả file trong một thư mục từ xa.
- `list_ssh_servers`: Liệt kê server đã cấu hình. **Chỉ xuất hiện khi cấu hình nhiều hơn 1 server.**

> Khi chỉ cấu hình **1 server**, tham số `server_name` của các tool là **tùy chọn** (bỏ trống cũng được, hệ thống tự dùng server duy nhất). Khi có **nhiều server**, `server_name` là bắt buộc để biết thao tác trên máy nào.

### Kết nối trực tiếp khi chưa lưu credential (Inline)

Các tool SSH/SFTP legacy nhận thêm `host`, `user`, `port`, `password`, `key_path`; truyền `host` +
`user` để kết nối khi chưa lưu credential. Các job tool 0.6.0 chỉ nhận configured alias.

Thứ tự ưu tiên resolve server:

1. Có `host` → dùng inline connection (ghi đè `server_name`).
2. Không có `host` nhưng có `server_name` → dùng server đã cấu hình.
3. Không có cả hai → fallback về server duy nhất đã cấu hình (hoặc báo lỗi nếu chưa cấu hình).

```text
ssh_execute_command
  command: df -h
  host: 192.168.1.50
  user: ops
  port: 2222
  key_path: ~/.ssh/toppion_ops_ed25519
```

> Lưu ý bảo mật: credential inline chỉ tồn tại trong tool call, không được ghi vào config hay log. Ưu tiên `key_path`; hạn chế truyền `password` plaintext qua MCP.

## Hướng dẫn sử dụng (Usage)

Sau khi server đã được khai báo, bạn thao tác qua **`server_name`** — giá trị phải khớp chính xác trường `name` trong cấu hình. Ví dụ với server có `"name": "toppion-linux"`:

**1. Chạy lệnh shell trên server**

```text
ssh_execute_command
  server_name: toppion-linux
  command: df -h
```

*Chỉ có 1 server?* Bỏ qua `server_name`:

```text
ssh_execute_command
  command: df -h
```

**2. Tạo thư mục từ xa**

```text
ssh_create_folder
  server_name: toppion-linux
  remote_path: /var/www/backend/logs
```

**3. Truyền file qua SFTP**

Upload file từ máy local lên server:

```text
sftp_upload_file
  server_name: toppion-linux
  local_path: /Users/namho/config/app.yml
  remote_path: /etc/app/config.yml
```

Download file từ server về máy local:

```text
sftp_download_file
  server_name: toppion-linux
  remote_path: /var/log/nginx/error.log
  local_path: /Users/namho/downloads/error.log
```

Liệt kê file trong một thư mục trên server:

```text
sftp_list_directory
  server_name: toppion-linux
  remote_path: /var/log
```

**4. Xem danh sách server đã cấu hình** (chỉ có khi cấu hình nhiều server)

```text
list_ssh_servers
```

> Mẹo: Muốn thao tác nhanh với nhiều server, hãy đặt `name` ngắn gọn và ổn định (VD: `production`, `staging`, `toppion-linux`) và dùng đúng alias đó trong mọi lời gọi tool.

## Changelog

### 0.6.0 - 2026-09-15

**New**
- Thêm runner-first durable jobs, structured results, idempotent reconnect, stdin/cancel và PTY
  service trên Windows/Linux.
- Thêm runner health, log preview/artifacts và SFTP transfer có scope + SHA-256.

**Changed**
- PowerShell/pwsh job nhận script UTF-8 riêng, không đi qua raw SSH command quoting.
- `runner=auto` ưu tiên service; Windows fallback agentless khi detach an toàn, không replay khi mất
  kết nối. Linux dùng legacy synchronous tool khi chưa có runner.

### 0.4.0 - 2026-08-17

**New**
- Thêm inline connection: mọi tool nhận thêm `host`, `user`, `port`, `password`, `key_path` để kết nối thẳng khi chưa có credential lưu sẵn.

### 0.2.0 - 2026-08-10

**Breaking**
- Xoá tool `ssh_smoke_test` và `ssh_smoke_test_all` khỏi MCP (vẫn dùng được qua CLI `--smoke-test`).

**Changed**
- Chỉ cấu hình 1 server → `server_name` là tùy chọn ở mọi tool; `list_ssh_servers` chỉ xuất hiện khi có nhiều server.

### 0.1.8 - 2026-08-10

**New**
- `SSH_SERVERS`, `--servers` và file JSON giờ chấp nhận cả object đơn `{...}` lẫn array `[{...}]`, không bắt buộc bọc mảng khi chỉ có 1 server.

### 0.1.7 - 2026-08-10

**Fixed**
- Khóa `mcp>=1.2.0,<2` để tránh `uvx` resolve nhầm lên `mcp` 2.0.0, bản này đã bỏ `mcp.server.fastmcp` khiến server crash với `ModuleNotFoundError: No module named 'mcp.server.fastmcp'`.

### 0.1.5 - 2026-07-05

**New**
- `list_ssh_servers`: Xem danh sách server đã cấu hình với thông tin auth đã sanitize, không in password.
- `ssh_smoke_test`: Kiểm tra nhanh một server bằng lệnh `hostname && whoami && pwd && uptime`.
- `ssh_smoke_test_all`: Kiểm tra nhanh toàn bộ server trong `ssh_servers.json`.
- CLI `--validate-config`: Kiểm tra config và cảnh báo vận hành mà không cần mở MCP client.

**Improved**
- Khuyến nghị cấu hình bằng `key_path` và user vận hành riêng thay vì mặc định `root` + password plaintext.
- Publish scripts dùng `UV_PUBLISH_TOKEN` thay vì lưu PyPI token trong source.
- `key_path` hỗ trợ `~` và biến môi trường.
- SSH command có timeout mặc định để tránh treo lâu khi server không phản hồi.

**Fixed**
- Sửa tương thích MCP SDK bằng cách bỏ tham số `description` không được hỗ trợ trong `FastMCP(...)`.
- `ssh_create_folder` quote remote path trước khi chạy `mkdir -p`.

Xem lịch sử đầy đủ tại [CHANGELOG.md](CHANGELOG.md).

## License
MIT

## Runner process ownership

The source tree includes runner selection, service packaging and `runner_health(server_name=null)`.
Actual service installation and remote acceptance remain unverified. Windows development installs use
`uv sync --extra dev --extra runner-windows`.

`runner=auto` prefers a compatible protocol-1 `capabilities.json` heartbeat at most 30 seconds old
with exact-tree ownership; `required` refuses missing/stale/unsafe runners and `disabled` never
selects them. SFTP capability reads cache at most five monotonic seconds per alias/host/account/spool.
Future timestamps beyond five seconds fail validation. Unsupported Windows detachment and Linux
without a healthy runner retain actionable errors and legacy synchronous guidance.
An unreadable capability marker makes the runner unavailable; transport loss or denied job writes
do not trigger fallback. Required mode reports marker access/permissions errors actionably.

Mode is committed inside the atomic submission, before `ready` becomes visible. The worker claims
only queued service jobs; an appearing service cannot steal an agentless job. If the service stops
after submission, its job remains queued for restart, without fallback/relaunch. Existing keys retain
their recorded owner even when runner policy or availability changes.

`job_input` persists immutable UTF-8 sequence files for nonterminal service jobs; identical retries
reuse bytes and different text conflicts. Sequence numbers start at 1 and stay below 10^20. The EOF
marker stays internal; PTY users can send terminal Ctrl-D bytes as described above. Service cancellation
publishes the validated `cancel.request` control once and polls for
terminal state/result for at most ten seconds. Lost acknowledgements reconnect for inspection only.
Agentless stdin remains unsupported; terminal cancellation is a no-op.
Input rechecks the current healthy marker with stdin=true before writing, bypassing the cache.
A valid cancellation present before worker launch prevents process creation, including cancellation
during the final capability check. PTY is service-only and rechecks current capability before launch.
Linux uses `openpty`, a session and controlling terminal **after** the existing delegated-cgroup gate;
exact cancellation still uses cgroup.kill, not process groups. Terminal output (including ANSI escapes,
echo and terminal line endings) is merged into stdout; stderr is empty. Buffered output drains before
the native exit is published, with a five-second terminal EOF drain deadline after the tree stops;
drain/protocol errors interrupt rather than masquerading as native success. Spool disk I/O must still
complete before handles are released. Ctrl-C targets the terminal foreground group; use `cancel_job`
for the entire exact owned tree. JobRequest identity/default hashes are unchanged.

PTY is advertised only after a real contained terminal child consumes input and produces expected
output with native exit 0. Windows uses native ConPTY on Windows 10 1809 / Server 2019 or newer: the
client is created suspended, assigned to the private no-breakaway Job Object, then resumed; output is
drained on its own thread before ConPTY closes. No pywinpty dependency or PATH change is required.
Native Linux tests require the existing opt-in `NPROX_TEST_CGROUP_ROOT` disposable delegated root;
without it (including this Windows development host), Linux deployment acceptance remains unverified.

Health preserves `{protocol,ssh,healthy,capabilities,reason}` and adds optional `runner`, `account`,
`disk`, `powershell_version` check objects and an `executables` map. Each check is
`{ok,detail,value}`: `ok=true` means the fact is available, false means unavailable (or zero free
disk bytes), and null means unknown. Check details are bounded sanitized messages. Values contain
only a marker-reported account, version, absolute path or nonnegative free byte count. Older health
objects remain valid with null checks and an empty executable map. Overall `healthy` still means
fresh compatible exact-tree service, not an aggregate of optional tool/disk checks. Missing Git
does not hide PowerShell, account or disk facts. A stale marker can retain diagnostic facts, but
does not become healthy because a path is present. Paths are **not executed or re-probed** by health;
the current worker does not measure PowerShell version, so its version value is null/unknown.

The total connection/retry/SFTP health deadline is `SSH_CONNECT_TIMEOUT + 5` seconds (15 by default);
the existing per-alias/spool cache lasts at most five seconds. Timeout/access/schema failure returns
structured unhealthy diagnostics, with `ssh=true` only if a connection succeeded. External caller
cancellation is not turned into a successful check. The nested versioned facts include native
platform/account, instance ID, UTC heartbeat, exact-tree/stdin/PTY flags, free disk bytes, absolute
PowerShell/Git/Node/.NET/Codex paths (null when absent), and PowerShell version when known. No remote
commands are needed; no environment or credentials are returned. Missing/stale markers return
structured unhealthy results even under `runner=required`. For example, call
`runner_health({"server_name":"windows-build"})` through the MCP tool form.

The ten questions in `evaluations/ssh_remote_runner_read_only.xml` use a fixed local fixture and only
health/status/log/preview/artifact metadata calls. `tests/test_runner_health.py` verifies all answers
through registered read-only tools. Its alias named `production` is synthetic; never run this fixture
against actual production infrastructure. It is not live service, transfer or connector acceptance.

The service loop calls `await worker.publish_capabilities()` regularly; the method throttles
publication to ten seconds and uses the existing independent disk-I/O boundary. Initial publication
requires spool/policy validation and a fixed contained child self-check; it never runs caller code.
Startup enforces private spool ownership/permissions. A noninherited native exclusive lock is held
through worker shutdown. On next acquisition only the previous valid 32-hex owner is recovered,
before new heartbeat/scan; its incomplete jobs become interrupted and are never reexecuted. Other
owners and ownerless claims remain untouched. Do not remove/replace `service-owner.lock` to bypass
duplicate-host errors. Keep it and the entire spool together across upgrades and restarts.
If recovery encounters a publication or metadata error, startup preserves the previous owner record
and refuses new heartbeat/scan. Repair the reported spool permissions/metadata, then restart; completed
recoveries are retained and the remaining jobs can be recovered without replay. Graceful service stop
can preserve a known native process exit code in interrupted state/result; hard-crash recovery has a
null exit when no native outcome was recorded. Both paths retain their durable outcome consistently.

Each child starts suspended, joins a private kill-on-close Job Object, then resumes with only its
stdin/stdout/stderr handles inherited. Waiting includes descendants after the leader exits.

Linux requires an explicitly supplied writable delegated cgroup v2 root with `cgroup.kill` support;
it fails capability checks without that delegation. A gate delays user execution and request
environment application until cgroup assignment. This contains ordinary descendants, including
`setsid`, but is not a sandbox against deliberate cgroup migration or external privileged launchers.
Native Linux acceptance can run with `NPROX_TEST_CGROUP_ROOT` set to an existing disposable delegated
root; tests do not create delegation or change systemd policy. Local Windows tests passed; native
Linux containment acceptance remains pending on a Python-equipped Linux test host.

Runner PowerShell/pwsh scripts can call the `codex` alias when `executables.codex` contains a validated
absolute executable path. All shells receive that configured path as `NPROX_CODEX_EXECUTABLE`:
cmd can use `call "%NPROX_CODEX_EXECUTABLE%"`, and sh/bash can use `"$NPROX_CODEX_EXECUTABLE"`.
The configured value takes precedence over request overrides. Other shells have no implicit alias;
their ordinary jobs remain supported, and no Codex directory or shim is added to PATH.

Cancelling an stdin-write caller does not retract bytes already being written. The managed process
retains and serializes that native operation; closing stdin waits for it without blocking the event
loop. A timeout/shutdown caller must cancel the managed process to release a nonreading child's pipe.
Each managed process lazily owns one stdin worker, isolated from other jobs and the shared executor.
Native tree cancellation does not use that worker. Waiting for completion drains/closes stdin and
waits asynchronously for its worker to exit, so blocked input in another job cannot delay publication.

## Runner installation and rollback

These are operator-run instructions, **not evidence of an installation**. Install the pinned reviewed
`ssh-mcp-vn==0.6.3` release into a persistent environment. Both `ssh-mcp-vn` (MCP client-side server)
and `ssh-mcp-runner` (remote local-spool worker) are included. The runner opens no network listener
and needs no SSH credentials. Never run a service from an ephemeral uvx cache.

Use a dedicated least-privilege service identity, a local filesystem with native file-lock support,
and existing absolute spool/cwd/executable paths. Reject symlinks, reparse points and shared/network
spools. The spool must be owned by the service identity; only that identity, SYSTEM and administrators
may mutate a Windows spool or inherit write access to its children. Linux rejects group/other write
bits; use mode 0700 and umask 0077. Align SSH/SFTP writer identity with that policy. A separate ordinary
SSH account granted spool write access is incompatible with this strict policy; do not loosen it to
make startup pass. Administrators can still change the spool: this is trusted-job containment, not a
hostile multi-tenant sandbox. Keep service control rights separate from ordinary job submission.

Spool requests/scripts/env/logs are durable and may contain sensitive job data. Provision disk quota,
monitor `disk_free_bytes`, and apply reviewed offline retention; automatic rotation/cleanup is absent.
Back up the complete spool and owner record only after stopping the service. Do not erase identity
records, replay ambiguous jobs with new keys, or restore an old snapshot over an active spool.

### Windows SCM

Provision a persistent Python 3.12+ environment with the reviewed wheel and `runner-windows` extra.
Choose a built-in/passwordless managed service account; the CLI never accepts a password. Its default
is `NT AUTHORITY\LocalService`, not LocalSystem. A regular password-requiring account needs a separately
approved credential-managed installation workflow; do not place its password in command lines.

For a fresh host, run the bundled installer from an elevated PowerShell. It creates the fixed venv,
private spool/work directories, stable Codex executable, auto-start service, and verifies the live
capability marker. Pass explicit executable paths when auto-detection is unsuitable:

```powershell
.\deploy\install-windows-runner.ps1 -PythonExe 'C:\Python312\python.exe' -CodexExe 'C:\Tools\Codex\codex.exe'
```

```powershell
py -3.12 -m venv 'C:\Program Files\nProxRunner\venv'
& 'C:\Program Files\nProxRunner\venv\Scripts\python.exe' -m pip install 'ssh-mcp-vn[runner-windows]==0.6.3'
```

Create the spool and grant only the chosen service SID plus SYSTEM/Administrators full control, remove
inherited broad grants, and set the owner to the service SID. Example for a **new empty** dedicated
directory with the default LocalService SID (administrator shell; review paths before applying):

```powershell
New-Item -ItemType Directory -Path 'C:\nprox\jobs'
icacls 'C:\nprox\jobs' /inheritance:r
icacls 'C:\nprox\jobs' /grant:r '*S-1-5-19:(OI)(CI)F' '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F'
icacls 'C:\nprox\jobs' /setowner '*S-1-5-19'
& 'C:\Program Files\nProxRunner\venv\Scripts\ssh-mcp-runner.exe' install-windows-service --spool 'C:\nprox\jobs' --cwd-root 'C:\build workspace' --executable 'powershell=C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' --executable 'codex=C:\Tools\Codex\codex.exe'
Start-Service -Name NProxSshRunner
Get-Service -Name NProxSshRunner
```

Provision cwd permissions separately for the same identity. Installation registers a fixed auto-start
service but does not start it; the registered command uses the absolute environment Python with
`-I -m ssh_mcp_windows_service`. Optional pywin32 imports remain isolated from normal Linux usage.
Verify `runner_health` through the configured alias and confirm account, paths and fresh heartbeat.
Windows SCM recovery actions are not configured by this package: configure approved restart-on-failure
policy separately if desired. Graceful stop/shutdown drains jobs; a crash closes Job Objects, then
the next service start recovers the previous owner's incomplete jobs as interrupted.

Uninstall: stop `NProxSshRunner`, wait for Stopped, then run the same absolute runner executable with
`remove-windows-service`. Removal refuses a running service and retains all spool/job data. To roll
back, stop/remove registration, restore the previously reviewed environment and matching policy,
register/start again, and verify health before enabling submissions. Preserve spool/alias/idempotency
keys; never roll an active job back to an agentless launcher. Review protocol compatibility before
downgrading; use `runner=required` to fail closed during maintenance.

### Linux systemd

Provision a dedicated `nprox-runner` account/group and persistent environment such as
`/opt/nprox-ssh-runner/venv`. Install the reviewed wheel there. The wheel contains
`share/ssh-mcp-vn/ssh-mcp-runner.service` under its installation prefix; copying it to systemd is an
explicit operator step. Review it first, then adapt `ExecStart` to the absolute venv executable:

```sh
python3.12 -m venv /opt/nprox-ssh-runner/venv
/opt/nprox-ssh-runner/venv/bin/python -m pip install ssh-mcp-vn==0.6.3
```

```ini
ExecStart=/opt/nprox-ssh-runner/venv/bin/ssh-mcp-runner run --spool /var/lib/nprox-ssh-runner/jobs --cwd-root /var/lib/nprox-ssh-runner --cgroup-root /sys/fs/cgroup/system.slice/ssh-mcp-runner.service --executable codex=/opt/codex/bin/codex
```

Keep `User=nprox-runner`, `Group=nprox-runner`, `UMask=0077`, `StateDirectoryMode=0700`,
`StateDirectory=nprox-ssh-runner/jobs`, **`Delegate=yes`**, `KillMode=mixed` and the bounded stop timeout.
The host must use cgroup v2 with writable delegated `cgroup.procs` and `cgroup.kill`; systemd must own
the matching cgroup path. Change `--cgroup-root` if the service unit/slice name changes. Process groups,
`setsid`, or a regular directory pretending to be a cgroup are not acceptable replacements.
The packaged unit creates a private state directory; provision its permitted work directories and
the SSH writer identity consistently. Do not grant jobs general sudo/systemd control.

```sh
sudo systemctl daemon-reload
sudo systemctl enable --now ssh-mcp-runner.service
sudo systemctl status ssh-mcp-runner.service
```

After installation verify healthy SSH/SFTP discovery and a small explicit test job. On stop the worker
drains its processes before systemd's final subtree cleanup; on crash/reboot the service manager
cleans the old service cgroup and a new lock owner recovers interrupted jobs without reexecution.
Running a worker manually outside that service manager does not prove orphaned Linux descendants
were cleaned up. No recovery guesses owners from heartbeat age or touches ownerless/foreign claims.

Uninstall: `sudo systemctl disable --now ssh-mcp-runner.service`, verify it stopped, remove only the
reviewed unit file from `/etc/systemd/system`, and run `daemon-reload`. Keep the spool and environment
until the retention/rollback decision. Rollback restores the prior reviewed environment/unit, reloads,
starts, and verifies health with the same spool and alias. Stop before replacing binaries or policy.

### Clean tool environment

Configure absolute `--executable tool=path` on the runner and corresponding absolute `executables`
in the MCP alias policy. `codex` must point directly to the intended executable, not rely on a shell
shim or app-injected PATH. Never prepend `OpenAI/Codex/bin` to PATH: it can shadow tools such as `git`.
PowerShell gets the configured `codex` alias; other shells use `NPROX_CODEX_EXECUTABLE` as described
above. Health reports null for missing tools rather than inventing paths. The child environment is
minimal; approve explicit request env values separately and never treat health as an env dump.

### Guarded disposable service acceptance

As of 2026-09-15 no `SSH_RUNNER_TEST_CONFIG` was supplied: live Windows SCM/Linux systemd restart,
hard-crash/reboot, native Linux containment and ChatGPT connector acceptance are **unverified**.
Local subprocess lock/crash and worker tests are not remote deployment proof.

Only an operator who has already installed isolated disposable services should run the harness. It
creates jobs/counter files and stops, starts, restarts and forcibly terminates the fixed service host;
it never installs services. Use a private absolute JSON file (maximum 64 KiB), no inline passwords,
tokens, environment or arbitrary commands. Authentication uses your approved SSH agent or key path.
The strict shape is shown below; `targets` may contain `linux`, `windows`, or both. Windows uses alias
`disposable-runner-windows`, platform `windows`, and paths such as `C:/ssh-runner-acceptance/jobs` and
`C:/ssh-runner-acceptance/work`. Each cwd must be the sole permitted root; spool/work must be separate
absolute paths with the literal `ssh-runner-acceptance` component. Service policy must match.

```json
{
  "version": 1,
  "disposable": true,
  "purpose": "ssh-runner-acceptance",
  "allow_service_restart": true,
  "targets": {
    "linux": {
      "server": {
        "name": "disposable-runner-linux", "host": "disposable.example.invalid",
        "user": "runner-test", "platform": "linux", "runner": "auto",
        "job_spool": "/srv/ssh-runner-acceptance/jobs",
        "allowed_cwd_roots": ["/srv/ssh-runner-acceptance/work"]
      },
      "cwd": "/srv/ssh-runner-acceptance/work"
    }
  }
}
```

The marker/name guard prevents accidental ordinary configs, not a malicious operator or DNS change.
Verify the isolated host out of band; the legacy SSH connector still disables host-key validation.
The test identity needs narrowly approved control of `NProxSshRunner` or passwordless sudo for only
the fixed `/usr/bin/systemctl` operations on `ssh-mcp-runner.service` (including the explicit main
process kill test). These privileges are for disposable acceptance, not normal job accounts.

```powershell
$env:SSH_RUNNER_TEST_CONFIG='C:\private-tests\runner-disposable.json'
$env:SSH_RUNNER_TEST_ALLOW_RESTART='disposable-services-only'
uv run pytest tests/integration/test_runner_service.py -v --tb=no --show-capture=no
```

Without the config the whole module skips during collection, before server import or SSH. With an
invalid/ambiguous config or missing second acknowledgement collection refuses safely. Never use
`--showlocals`, verbose SSH logging or captured config dumps for live tests. Summaries must omit host,
credential, account/path and job payload details; retain detailed evidence privately. Tests verify
fresh health/tool paths, native exit/log reconnect, service preference, safe unavailable-service
fallback, graceful interruption with consistent native exit/state/result, hard-crash interruption with
null exit, no double execution, and a new job after restart. A real
host reboot remains a separate operator-controlled acceptance step, not performed by this harness.
Retain the test's tiny counters and durable jobs for inspection; no blanket remote cleanup is run.
The existing `scripts/smoke_test.py` expects a standard server config, **not this guarded wrapper**;
do not pass it this file or any unknown production configuration.
