Skill: xeon0v0/ssh-filemanager (version 1.0.0)

--- name: tailnet_ssh_file_manager description: Manage files on remote machines over Tailscale SSH (Tailnet). Use this skill when the user needs to list, read, write, delete, move, copy, search, chmod, push, or pull files on a remote OpenClaw node via a Tailscale SSH connection. Always prefer scp for files larger than 3KB to avoid the OpenClaw invoke payload limit. --- # Tailnet SSH File Manager ## Overview This skill enables file management between the OpenClaw Gateway and remote OpenClaw Nodes through **Tailscale SSH** (Tailnet). It wraps system `ssh`, `scp`, and `sftp` commands via the helper script `scripts/ssh_file_manager.py`. ## Pre-requisites 1. The local machine (Gateway) must be connected to the same **Tailnet** as the target node. 2. **Tailscale SSH** must be enabled on the target node and allowed by Tailscale ACLs. 3. The target node must be reachable via its **Tailscale IP** or **MagicDNS hostname** (e.g. `node-name.tailXXXX.ts.net`). ## Workflow 1. Determine the remote node's Tailscale identifier (`host`). Ask the user if it is ambiguous. 2. Determine the requested operation (`action`) and the relevant `path`(s). 3. **File size check**: - If reading/writing text content and the estimated size is **< 3KB**, you may use `read` or `write` with inline content. - If the file is **>= 3KB**, or if transferring binary files, **always** use `push` (local -> remote) or `pull` (remote -> local) via `scp`. 4. Run the helper script: ```bash python3 {baseDir}/scripts/ssh_file_manager.py <action> --host <host> [args...] ``` 5. Parse the JSON output and present results to the user. If `success` is `false`, surface the `error` message. ## Supported Actions | Action | Description | Command Example | |---|---|---| | `list` | List directory entries with size, mode, and mtime. | `python3 {baseDir}/scripts/ssh_file_manager.py list --host node.tailXXXX.ts.net --path /home/user` | | `read` | Read a remote file as text or base64. | `python3 {baseDir}/scripts/ssh_file_manager.py read --host node.tailXXXX.ts.net --path /home/user/file.txt` | | `write` | Write text or base64 content to a remote file. | `python3 {baseDir}/scripts/ssh_file_manager.py write --host node.tailXXXX.ts.net --path /home/user/file.txt --content "Hello"` | | `delete` | Delete a remote file or directory recursively. | `python3 {baseDir}/scripts/ssh_file_manager.py delete --host node.tailXXXX.ts.net --path /home/user/old.txt` | | `move` | Move/rename a remote file or directory. | `python3 {baseDir}/scripts/ssh_file_manager.py move --host node.tailXXXX.ts.net --src /tmp/a --dst /tmp/b` | | `copy` | Copy a remote file or directory recursively. | `python3 {baseDir}/scripts/ssh_file_manager.py copy --host node.tailXXXX.ts.net --src /tmp/a --dst /tmp/b` | | `stat` | Get file metadata (size, mode, uid, gid, mtime, is_dir). | `python3 {baseDir}/scripts/ssh_file_manager.py stat --host node.tailXXXX.ts.net --path /home/user/file.txt` | | `find` | Search for files by name pattern under a path. | `python3 {baseDir}/scripts/ssh_file_manager.py find --host node.tailXXXX.ts.net --path /home/user --name "*.log"` | | `chmod` | Change file permissions. | `python3 {baseDir}/scripts/ssh_file_manager.py chmod --host node.tailXXXX.ts.net --path /home/user/script.sh --mode 755` | | `push` | Copy a local file to the remote node via scp. | `python3 {baseDir}/scripts/ssh_file_manager.py push --host node.tailXXXX.ts.net --local /tmp/local.bin --remote /tmp/remote.bin` | | `pull` | Copy a remote file to the local machine via scp. | `python3 {baseDir}/scripts/ssh_file_manager.py pull --host node.tailXXXX.ts.net --remote /tmp/remote.bin --local /tmp/local.bin` | ## Safety Rules - **Destructive operations** (`write`, `delete`, `move`, `chmod`): Always ask the user for explicit confirmation before invoking the script. - **Payload limit**: Do not attempt to inline base64-encode files larger than 3KB through OpenClaw's `node.invoke`. Use `push`/`pull` (scp) instead. - **Path sanitization**: Do not traverse above `/` unless explicitly requested. Verify absolute paths when provided by the user. - **Connection check**: If a command fails with a connection error, you may first run `python3 {baseDir}/scripts/ssh_tunnel.py check --host <host>` to verify Tailscale SSH reachability. ## Edge Cases - **Target host offline or not in the tailnet** -> connection timeout. Run `ssh_tunnel.py check` to diagnose. - **Permission denied** -> user lacks SSH/ACL access or file permissions on the remote node. - **Remote path does not exist** -> script returns `success: false` with details. - **Binary file read** -> use `read` with `--format base64`. - **Remote node missing Python 3** -> `list` and `stat` may fail; fallback to raw `ssh` commands if needed.

path: scripts/ssh_file_manager.py
content: #!/usr/bin/env python3
"""Tailnet SSH File Manager — remote file ops over Tailscale SSH."""

import argparse
import base64
import json
import os
import subprocess
import sys
import tempfile


def run_ssh(host, command, timeout=60, stdin=None):
    """Run a command over SSH and return (returncode, stdout, stderr)."""
    cmd = [
        "ssh",
        "-o", "BatchMode=yes",
        "-o", "ConnectTimeout=10",
        "-o", "StrictHostKeyChecking=accept-new",
        host,
        command,
    ]
    try:
        result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, input=stdin)
        return result.returncode, result.stdout, result.stderr
    except subprocess.TimeoutExpired:
        return -1, "", "SSH connection timed out"
    except Exception as e:
        return -1, "", str(e)


def run_scp(src, dst, timeout=120):
    """Run scp command."""
    cmd = [
        "scp",
        "-o", "BatchMode=yes",
        "-o", "ConnectTimeout=10",
        "-o", "StrictHostKeyChecking=accept-new",
        src,
        dst,
    ]
    try:
        result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
        return result.returncode, result.stdout, result.stderr
    except subprocess.TimeoutExpired:
        return -1, "", "SCP connection timed out"
    except Exception as e:
        return -1, "", str(e)


def ok(data):
    print(json.dumps({"success": True, "data": data}, ensure_ascii=False))


def err(message, stderr=""):
    payload = {"success": False, "error": message}
    if stderr:
        payload["stderr"] = stderr
    print(json.dumps(payload, ensure_ascii=False))
    sys.exit(1)


def _escape_path(path):
    """Escape single quotes for shell usage inside single-quoted strings."""
    return path.replace("'", "'\"'\"'")


def action_list(args):
    py_cmd = (
        "python3 -c $'import os, json\\n"
        f"path=\\\"{_escape_path(args.path)}\\\"\\n"
        "entries=[]\\n"
        "for name in os.listdir(path):\\n"
        "  try:\\n"
        "    full=os.path.join(path,name)\\n"
        "    st=os.lstat(full)\\n"
        "    entries.append({\"name\":name,\"size\":st.st_size,\"mode\":oct(st.st_mode),\"is_dir\":os.path.isdir(full),\"mtime\":st.st_mtime})\\n"
        "  except Exception as e:\\n"
        "    entries.append({\"name\":name,\"error\":str(e)})\\n"
        "print(json.dumps(entries))\\'"
    )
    rc, out, er = run_ssh(args.host, py_cmd)
    if rc != 0:
        err("Failed to list directory", er)
    try:
        data = json.loads(out)
        ok({"path": args.path, "entries": data})
    except json.JSONDecodeError:
        err("Failed to parse remote listing", out + "\n" + er)


def action_read(args):
    if args.format == "base64":
        rc, out, er = run_ssh(args.host, f"base64 -w 0 '{_escape_path(args.path)}'")
        if rc != 0:
            err("Failed to read file", er)
        ok({"path": args.path, "format": "base64", "content": out.strip()})
    else:
        rc, out, er = run_ssh(args.host, f"cat '{_escape_path(args.path)}'")
        if rc != 0:
            err("Failed to read file", er)
        ok({"path": args.path, "format": "text", "content": out})


def action_write(args):
    if args.content_base64:
        try:
            data = base64.b64decode(args.content_base64)
        except Exception as e:
            err(f"Invalid base64 content: {e}")
        with tempfile.NamedTemporaryFile(mode="wb", delete=False, suffix=".tmp") as f:
            f.write(data)
            tmp_path = f.name
    else:
        with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".tmp") as f:
            f.write(args.content)
            tmp_path = f.name
    try:
        rc, out, er = run_scp(tmp_path, f"{args.host}:'{_escape_path(args.path)}'")
        if rc != 0:
            err("Failed to write file", er)
        ok({"path": args.path, "bytes_written": os.path.getsize(tmp_path)})
    finally:
        os.unlink(tmp_path)


def action_delete(args):
    rc, out, er = run_ssh(args.host, f"rm -rf '{_escape_path(args.path)}'")
    if rc != 0:
        err("Failed to delete", er)
    ok({"deleted": args.path})


def action_move(args):
    rc, out, er = run_ssh(args.host, f"mv '{_escape_path(args.src)}' '{_escape_path(args.dst)}'")
    if rc != 0:
        err("Failed to move", er)
    ok({"src": args.src, "dst": args.dst})


def action_copy(args):
    rc, out, er = run_ssh(args.host, f"cp -r '{_escape_path(args.src)}' '{_escape_path(args.dst)}'")
    if rc != 0:
        err("Failed to copy", er)
    ok({"src": args.src, "dst": args.dst})


def action_stat(args):
    py_cmd = (
        "python3 -c $'import os, json\\n"
        f"path=\\\"{_escape_path(args.path)}\\\"\\n"
        "st=os.stat(path)\\n"
        "print(json.dumps({\"size\":st.st_size,\"mode\":oct(st.st_mode),\"uid\":st.st_uid,\"gid\":st.st_gid,\"mtime\":st.st_mtime,\"is_dir\":os.path.isdir(path)}))\\'"
    )
    rc, out, er = run_ssh(args.host, py_cmd)
    if rc != 0:
        err("Failed to stat", er)
    try:
        ok({"path": args.path, "stat": json.loads(out)})
    except json.JSONDecodeError:
        err("Failed to parse stat output", out + "\n" + er)


def action_find(args):
    rc, out, er = run_ssh(args.host, f"find '{_escape_path(args.path)}' -name '{_escape_path(args.name)}'")
    # find returns 1 when no matches; that is not an error for us.
    if rc not in (0, 1):
        err("Failed to find", er)
    files = [line for line in out.strip().split("\n") if line]
    ok({"path": args.path, "pattern": args.name, "matches": files})


def action_chmod(args):
    rc, out, er = run_ssh(args.host, f"chmod {args.mode} '{_escape_path(args.path)}'")
    if rc != 0:
        err("Failed to chmod", er)
    ok({"path": args.path, "mode": args.mode})


def action_push(args):
    if not os.path.exists(args.local):
        err(f"Local file does not exist: {args.local}")
    rc, out, er = run_scp(args.local, f"{args.host}:'{_escape_path(args.remote)}'")
    if rc != 0:
        err("Failed to push file", er)
    ok({"local": args.local, "remote": args.remote})


def action_pull(args):
    rc, out, er = run_scp(f"{args.host}:'{_escape_path(args.remote)}'", args.local)
    if rc != 0:
        err("Failed to pull file", er)
    ok({"remote": args.remote, "local": args.local})


def main():
    parser = argparse.ArgumentParser(description="Tailnet SSH File Manager")
    subparsers = parser.add_subparsers(dest="action", required=True)

    def add_host(p):
        p.add_argument("--host", required=True, help="Tailscale hostname or IP")

    p = subparsers.add_parser("list")
    add_host(p)
    p.add_argument("--path", required=True)
    p.set_defaults(func=action_list)

    p = subparsers.add_parser("read")
    add_host(p)
    p.add_argument("--path", required=True)
    p.add_argument("--format", choices=["text", "base64"], default="text")
    p.set_defaults(func=action_read)

    p = subparsers.add_parser("write")
    add_host(p)
    p.add_argument("--path", required=True)
    group = p.add_mutually_exclusive_group(required=True)
    group.add_argument("--content", help="Text content to write")
    group.add_argument("--content-base64", help="Base64-encoded content to write")
    p.set_defaults(func=action_write)

    p = subparsers.add_parser("delete")
    add_host(p)
    p.add_argument("--path", required=True)
    p.set_defaults(func=action_delete)

    p = subparsers.add_parser("move")
    add_host(p)
    p.add_argument("--src", required=True)
    p.add_argument("--dst", required=True)
    p.set_defaults(func=action_move)

    p = subparsers.add_parser("copy")
    add_host(p)
    p.add_argument("--src", required=True)
    p.add_argument("--dst", required=True)
    p.set_defaults(func=action_copy)

    p = subparsers.add_parser("stat")
    add_host(p)
    p.add_argument("--path", required=True)
    p.set_defaults(func=action_stat)

    p = subparsers.add_parser("find")
    add_host(p)
    p.add_argument("--path", required=True)
    p.add_argument("--name", required=True)
    p.set_defaults(func=action_find)

    p = subparsers.add_parser("chmod")
    add_host(p)
    p.add_argument("--path", required=True)
    p.add_argument("--mode", required=True)
    p.set_defaults(func=action_chmod)

    p = subparsers.add_parser("push")
    add_host(p)
    p.add_argument("--local", required=True)
    p.add_argument("--remote", required=True)
    p.set_defaults(func=action_push)

    p = subparsers.add_parser("pull")
    add_host(p)
    p.add_argument("--remote", required=True)
    p.add_argument("--local", required=True)
    p.set_defaults(func=action_pull)

    args = parser.parse_args()
    args.func(args)


if __name__ == "__main__":
    main()


path: scripts/ssh_tunnel.py
content: #!/usr/bin/env python3
"""Tailnet SSH tunnel helper — connection checks over Tailscale SSH."""

import argparse
import json
import subprocess
import sys


def main():
    parser = argparse.ArgumentParser(description="Tailnet SSH Tunnel Helper")
    parser.add_argument("--host", required=True, help="Tailscale hostname or IP")
    args = parser.parse_args()

    cmd = [
        "ssh",
        "-o", "BatchMode=yes",
        "-o", "ConnectTimeout=5",
        "-o", "StrictHostKeyChecking=accept-new",
        args.host,
        "echo tailnet-ssh-ok",
    ]
    try:
        result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
        if result.returncode == 0 and "tailnet-ssh-ok" in result.stdout:
            print(json.dumps({
                "success": True,
                "reachable": True,
                "host": args.host,
            }))
        else:
            print(json.dumps({
                "success": False,
                "reachable": False,
                "host": args.host,
                "error": result.stderr.strip() or result.stdout.strip(),
            }))
            sys.exit(1)
    except subprocess.TimeoutExpired:
        print(json.dumps({
            "success": False,
            "reachable": False,
            "host": args.host,
            "error": "SSH connection timed out",
        }))
        sys.exit(1)
    except Exception as e:
        print(json.dumps({
            "success": False,
            "reachable": False,
            "host": args.host,
            "error": str(e),
        }))
        sys.exit(1)


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)