runspec-linux 0.6.2__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of runspec-linux might be problematic. Click here for more details.

Files changed (33) hide show
  1. {runspec_linux-0.6.2 → runspec_linux-0.7.0}/CHANGELOG.md +41 -0
  2. {runspec_linux-0.6.2 → runspec_linux-0.7.0}/PKG-INFO +4 -3
  3. {runspec_linux-0.6.2 → runspec_linux-0.7.0}/pyproject.toml +18 -3
  4. runspec_linux-0.7.0/runspec_linux/_escalation.py +20 -0
  5. runspec_linux-0.7.0/runspec_linux/fs/__init__.py +12 -0
  6. runspec_linux-0.7.0/runspec_linux/fs/_dispatch.py +63 -0
  7. runspec_linux-0.7.0/runspec_linux/fs/_worker.py +32 -0
  8. runspec_linux-0.7.0/runspec_linux/fs/cli.py +131 -0
  9. runspec_linux-0.7.0/runspec_linux/investigate.py +50 -0
  10. {runspec_linux-0.6.2 → runspec_linux-0.7.0}/runspec_linux/logs.py +15 -4
  11. runspec_linux-0.7.0/runspec_linux/network.py +80 -0
  12. runspec_linux-0.7.0/runspec_linux/perf.py +54 -0
  13. {runspec_linux-0.6.2 → runspec_linux-0.7.0}/runspec_linux/runspec.toml +392 -0
  14. {runspec_linux-0.6.2 → runspec_linux-0.7.0}/runspec_linux/services.py +4 -1
  15. runspec_linux-0.7.0/tests/test_escalation.py +34 -0
  16. runspec_linux-0.7.0/tests/test_fs.py +51 -0
  17. runspec_linux-0.6.2/runspec_linux/network.py +0 -34
  18. {runspec_linux-0.6.2 → runspec_linux-0.7.0}/.gitignore +0 -0
  19. {runspec_linux-0.6.2 → runspec_linux-0.7.0}/runspec_linux/__init__.py +0 -0
  20. {runspec_linux-0.6.2 → runspec_linux-0.7.0}/runspec_linux/commands.py +0 -0
  21. {runspec_linux-0.6.2 → runspec_linux-0.7.0}/runspec_linux/containers.py +0 -0
  22. {runspec_linux-0.6.2 → runspec_linux-0.7.0}/runspec_linux/files.py +0 -0
  23. {runspec_linux-0.6.2 → runspec_linux-0.7.0}/runspec_linux/nc_command.py +0 -0
  24. {runspec_linux-0.6.2 → runspec_linux-0.7.0}/runspec_linux/packages.py +0 -0
  25. {runspec_linux-0.6.2 → runspec_linux-0.7.0}/runspec_linux/power.py +0 -0
  26. {runspec_linux-0.6.2 → runspec_linux-0.7.0}/runspec_linux/security.py +0 -0
  27. {runspec_linux-0.6.2 → runspec_linux-0.7.0}/runspec_linux/sudoers.py +0 -0
  28. {runspec_linux-0.6.2 → runspec_linux-0.7.0}/runspec_linux/system_info.py +0 -0
  29. {runspec_linux-0.6.2 → runspec_linux-0.7.0}/runspec_linux/venvs.py +0 -0
  30. {runspec_linux-0.6.2 → runspec_linux-0.7.0}/runspec_linux/webops.py +0 -0
  31. {runspec_linux-0.6.2 → runspec_linux-0.7.0}/tests/__init__.py +0 -0
  32. {runspec_linux-0.6.2 → runspec_linux-0.7.0}/tests/test_nc_send.py +0 -0
  33. {runspec_linux-0.6.2 → runspec_linux-0.7.0}/tests/test_webops.py +0 -0
@@ -1,5 +1,46 @@
1
1
  # runspec-linux Changelog
2
2
 
3
+ ## [0.7.0] — 2026-08-01
4
+
5
+ Enhancements for corporate application support, devops, and performance work.
6
+
7
+ ### Added — filesystem (folded from runspec-fs)
8
+
9
+ - The **`fs`** runnable is now folded into the Linux pack (backed by the
10
+ dependency-free `runspec-fs-core`): one verb per Filesystem MCP tool
11
+ (`read-text-file` … `move-file`), reads `autonomous` and mutations `confirm`.
12
+ `runspec serve` flattens the verbs into leaf `fs_<verb>` MCP tools, so a
13
+ runspec-console agent gets local **and** remote filesystem access on every
14
+ fleet host through the OS pack it already installs — no separate `runspec-fs`
15
+ needed. Per-invocation `--run-as <user>` / `--become-method` are inherited on
16
+ every verb (POSIX escalation, no directory jail).
17
+
18
+ ### Added — new runnables
19
+
20
+ - **Perf tuning & analysis:** `get-sysctl`, `set-sysctl` (a privileged write —
21
+ `confirm`, optional `--persist` to `/etc/sysctl.d`), `cpu-info` (governor /
22
+ frequency / topology), `pressure` (kernel PSI stats).
23
+ - **App discovery & investigation:** `service-detail` (a unit's ExecStart binary,
24
+ working dir, user, env files, unit-file path, state), `open-ports` (listening
25
+ port → pid → process), `owning-package` (which package owns a path, dpkg/rpm),
26
+ `failed-units`.
27
+ - **Network deep-diagnostics:** `trace-route`, `route-table`, `socket-stats`,
28
+ `dns-config`.
29
+
30
+ ### Added — `--run-as` / `--sudo` on the privileged runnables
31
+
32
+ - `restart-service`, `tail-log`, `search-log`, `journalctl`, `set-sysctl`,
33
+ `service-detail`, and `open-ports` now take a mutually-exclusive
34
+ **`--run-as <user>`** / **`--sudo`** pair (plus `--become-method sudo|su`) so
35
+ they can run against root-owned / service-account-owned resources.
36
+ Escalation is internal (`sudo -n -u` / `su`), governed by an `exclusive` group
37
+ so the two flags can't be combined.
38
+
39
+ ### Changed
40
+
41
+ - Requires `runspec >= 0.44.0` (leaf-tool inherited globals via `runspec serve`)
42
+ and `runspec-linux-core >= 0.4.0`; adds a `runspec-fs-core >= 0.2.0` dependency.
43
+
3
44
  ## [0.6.2] — 2026-07-08
4
45
 
5
46
  Pull in `runspec-linux-core >= 0.3.5` so `install-into-venv` now normalises the
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runspec-linux
3
- Version: 0.6.2
3
+ Version: 0.7.0
4
4
  Summary: Linux system admin runnables for runspec
5
5
  Requires-Python: >=3.10
6
- Requires-Dist: runspec-linux-core>=0.3.5
6
+ Requires-Dist: runspec-fs-core>=0.2.0
7
+ Requires-Dist: runspec-linux-core>=0.4.0
7
8
  Requires-Dist: runspec-webops-core>=0.1.0
8
- Requires-Dist: runspec>=0.27.0
9
+ Requires-Dist: runspec>=0.44.0
9
10
  Provides-Extra: dev
10
11
  Requires-Dist: mypy; extra == 'dev'
11
12
  Requires-Dist: pytest>=8.0; extra == 'dev'
@@ -4,13 +4,14 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "runspec-linux"
7
- version = "0.6.2"
7
+ version = "0.7.0"
8
8
  requires-python = ">=3.10"
9
9
  description = "Linux system admin runnables for runspec"
10
10
  dependencies = [
11
- "runspec>=0.27.0",
12
- "runspec-linux-core>=0.3.5",
11
+ "runspec>=0.44.0",
12
+ "runspec-linux-core>=0.4.0",
13
13
  "runspec-webops-core>=0.1.0",
14
+ "runspec-fs-core>=0.2.0",
14
15
  ]
15
16
 
16
17
  [project.scripts]
@@ -27,6 +28,18 @@ journalctl = "runspec_linux.logs:main_journalctl"
27
28
  ping-host = "runspec_linux.network:main_ping_host"
28
29
  check-port = "runspec_linux.network:main_check_port"
29
30
  show-connections = "runspec_linux.network:main_show_connections"
31
+ trace-route = "runspec_linux.network:main_trace_route"
32
+ route-table = "runspec_linux.network:main_route_table"
33
+ socket-stats = "runspec_linux.network:main_socket_stats"
34
+ dns-config = "runspec_linux.network:main_dns_config"
35
+ get-sysctl = "runspec_linux.perf:main_get_sysctl"
36
+ set-sysctl = "runspec_linux.perf:main_set_sysctl"
37
+ cpu-info = "runspec_linux.perf:main_cpu_info"
38
+ pressure = "runspec_linux.perf:main_pressure"
39
+ service-detail = "runspec_linux.investigate:main_service_detail"
40
+ open-ports = "runspec_linux.investigate:main_open_ports"
41
+ owning-package = "runspec_linux.investigate:main_owning_package"
42
+ failed-units = "runspec_linux.investigate:main_failed_units"
30
43
  find-large-files = "runspec_linux.files:main_find_large_files"
31
44
  backup-files = "runspec_linux.files:main_backup_files"
32
45
  last-logins = "runspec_linux.security:main_last_logins"
@@ -46,6 +59,8 @@ install-into-venv = "runspec_linux.venvs:main_install_into_venv"
46
59
  configure-pip = "runspec_linux.venvs:main_configure_pip"
47
60
  nc-command = "runspec_linux.nc_command:main"
48
61
  which = "runspec_linux.commands:main_which"
62
+ # Filesystem (folded from runspec-fs; backed by runspec-fs-core)
63
+ fs = "runspec_linux.fs.cli:main"
49
64
  # Web / TLS probes (runspec-webops-core; cross-platform)
50
65
  cert-check = "runspec_linux.webops:main_cert_check"
51
66
  cert-chain = "runspec_linux.webops:main_cert_chain"
@@ -0,0 +1,20 @@
1
+ """Shared wrapper helper for the ``--run-as`` / ``--sudo`` / ``--become-method``
2
+ escalation args.
3
+
4
+ The privileged runnables declare a mutually-exclusive ``escalation`` group with
5
+ ``--run-as <user>`` and ``--sudo`` (a flag = "run as root"), plus
6
+ ``--become-method sudo|su``. The group guarantees at most one of run-as/sudo is
7
+ set, so this resolves them to the ``(run_as, become_method)`` the core helpers
8
+ take: ``--sudo`` → ``run_as="root"``, ``--run-as x`` → ``run_as="x"``, neither →
9
+ ``run_as=None`` (no escalation, runs as the login user).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import runspec as rs
15
+
16
+
17
+ def resolve_escalation(spec: rs.RunSpec) -> tuple[str | None, str]:
18
+ """Return ``(run_as, become_method)`` from a spec carrying the escalation args."""
19
+ run_as = "root" if spec.sudo.value else (spec.run_as.value or None)
20
+ return run_as, spec.become_method.value
@@ -0,0 +1,12 @@
1
+ """The ``fs`` runnable folded into runspec-linux.
2
+
3
+ The filesystem is part of the operating system, so the Linux pack ships the
4
+ filesystem runnable directly (backed by the dependency-free ``runspec-fs-core``)
5
+ rather than leaving it to the separate ``runspec-fs`` package. Installing
6
+ runspec-linux therefore gives a runspec-console agent local *and* remote
7
+ filesystem access on every fleet host — one verb per Filesystem MCP tool,
8
+ flattened by ``runspec serve`` into leaf ``fs_<verb>`` tools.
9
+
10
+ The code mirrors ``runspec-fs`` (``cli`` + runspec-free ``_dispatch`` /
11
+ ``_worker``); all logic lives in ``runspec_fs_core``.
12
+ """
@@ -0,0 +1,63 @@
1
+ """Verb → core-helper dispatch, importing only ``runspec_fs_core`` (no runspec).
2
+
3
+ Shared by ``cli.py`` (the in-process path) and ``_worker.py`` (the ``--run-as``
4
+ path), so both map a verb name + a plain-JSON kwargs dict to the same core
5
+ helper. Keeping it runspec-free means the worker — which runs as a *different* OS
6
+ user under sudo/su — needs only the pure core on its import path, not runspec.
7
+
8
+ A verbatim copy of ``runspec_fs._dispatch`` so the Linux pack stays
9
+ self-contained; the logic is the shared ``runspec_fs_core``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ from runspec_fs_core import (
17
+ allowed_directories,
18
+ create_directory,
19
+ directory_tree,
20
+ edit_file,
21
+ get_file_info,
22
+ list_directory,
23
+ list_directory_with_sizes,
24
+ move_file,
25
+ read_media_file,
26
+ read_multiple_files,
27
+ read_text_file,
28
+ search_files,
29
+ write_file,
30
+ )
31
+
32
+
33
+ def run_verb(verb: str, kwargs: dict[str, Any]) -> dict[str, Any]:
34
+ """Run ``verb`` with plain ``kwargs`` (already unpacked from the parsed spec,
35
+ JSON-round-trip safe). Raises on failure like the core helpers do."""
36
+ if verb == "read-text-file":
37
+ return read_text_file(kwargs["path"], head=kwargs.get("head"), tail=kwargs.get("tail"))
38
+ if verb == "read-media-file":
39
+ return read_media_file(kwargs["path"])
40
+ if verb == "read-multiple-files":
41
+ return read_multiple_files(kwargs["paths"])
42
+ if verb == "list-directory":
43
+ return list_directory(kwargs["path"])
44
+ if verb == "list-directory-with-sizes":
45
+ return list_directory_with_sizes(kwargs["path"], sort_by=kwargs["sort_by"])
46
+ if verb == "directory-tree":
47
+ return directory_tree(kwargs["path"], exclude=kwargs.get("exclude"))
48
+ if verb == "search-files":
49
+ return search_files(kwargs["path"], kwargs["pattern"], exclude=kwargs.get("exclude"))
50
+ if verb == "get-file-info":
51
+ return get_file_info(kwargs["path"])
52
+ if verb == "list-allowed-directories":
53
+ return allowed_directories()
54
+ if verb == "write-file":
55
+ return write_file(kwargs["path"], kwargs["content"])
56
+ if verb == "edit-file":
57
+ edits = [tuple(pair) for pair in kwargs["edits"]]
58
+ return edit_file(kwargs["path"], edits, dry_run=kwargs["dry_run"])
59
+ if verb == "create-directory":
60
+ return create_directory(kwargs["path"])
61
+ if verb == "move-file":
62
+ return move_file(kwargs["source"], kwargs["destination"])
63
+ raise ValueError(f"unknown verb: {verb!r}")
@@ -0,0 +1,32 @@
1
+ """Run-as worker: read ``{"verb": ..., "kwargs": ...}`` JSON from stdin, run the
2
+ verb via the pure core, print the JSON result. ``cli.py`` invokes this as the
3
+ target OS user under ``sudo``/``su`` when ``--run-as`` is set, so an in-process
4
+ filesystem verb runs with that user's permissions.
5
+
6
+ Imports only ``runspec_linux.fs._dispatch`` (→ ``runspec_fs_core``), never
7
+ ``runspec`` — the escalated user only needs the pure core importable, and the
8
+ payload is already fully resolved, so no ``runspec.toml`` lookup happens here.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import sys
15
+
16
+ from runspec_fs_core import FsCoreError
17
+
18
+ from runspec_linux.fs._dispatch import run_verb
19
+
20
+
21
+ def main() -> None:
22
+ payload = json.loads(sys.stdin.read())
23
+ try:
24
+ result = run_verb(payload["verb"], payload["kwargs"])
25
+ except (FsCoreError, OSError, ValueError) as exc:
26
+ print(json.dumps({"error": str(exc), "kind": type(exc).__name__}, default=str))
27
+ sys.exit(1)
28
+ print(json.dumps(result, default=str))
29
+
30
+
31
+ if __name__ == "__main__":
32
+ main()
@@ -0,0 +1,131 @@
1
+ """cli.py — the ``fs`` entry point (folded into runspec-linux).
2
+
3
+ One binary, a verb per Filesystem MCP tool. ``rs.parse("fs")`` resolves the
4
+ subcommand and its args (from the merged ``[fs]`` tables in the pack's
5
+ ``runspec.toml``); we unpack each ``.value`` at the boundary into a plain kwargs
6
+ dict and run the matching :mod:`runspec_fs_core` helper.
7
+
8
+ **Per-invocation ``--run-as``** (a global arg, like ``runspec-linux``'s venv
9
+ runnables): when set to a different OS user, the resolved ``(verb, kwargs)`` is
10
+ shipped over stdin to :mod:`runspec_linux.fs._worker` re-invoked as that user via
11
+ ``sudo -n -u`` / ``su`` (:func:`runspec_fs_core.build_become`), so the in-process
12
+ verb runs with that user's permissions. Access is bounded by that user's OS
13
+ permissions — there is no directory jail.
14
+
15
+ A verbatim copy of ``runspec_fs.cli`` with the local ``_dispatch`` / ``_worker``
16
+ import paths; the logic is the shared ``runspec_fs_core``.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import subprocess
23
+ import sys
24
+ from typing import Any
25
+
26
+ import runspec as rs
27
+ from runspec_fs_core import BecomeError, FsCoreError, build_become, current_user, escalation_supported
28
+
29
+ from runspec_linux.fs._dispatch import run_verb
30
+
31
+
32
+ def _emit(payload: dict[str, Any]) -> None:
33
+ print(json.dumps(payload, default=str))
34
+
35
+
36
+ def _pair_edits(old_text: list[str], new_text: list[str]) -> list[list[str]]:
37
+ """Zip the two parallel ``--old-text`` / ``--new-text`` lists into
38
+ ``[old, new]`` pairs, erroring if their counts differ."""
39
+ if len(old_text) != len(new_text):
40
+ raise ValueError(f"--old-text / --new-text count mismatch: {len(old_text)} vs {len(new_text)} (pair them by position)")
41
+ return [[old, new] for old, new in zip(old_text, new_text, strict=False)]
42
+
43
+
44
+ def _verb_kwargs(spec: rs.RunSpec) -> dict[str, Any]:
45
+ """Unpack the parsed spec into a plain, JSON-round-trip-safe kwargs dict for
46
+ the active verb (paths as strings), so the same dict serves the in-process
47
+ path and the run-as worker."""
48
+ command = spec.runspec_command
49
+
50
+ if command == "read-text-file":
51
+ return {"path": str(spec.path.value), "head": spec.head.value, "tail": spec.tail.value}
52
+ if command == "read-media-file":
53
+ return {"path": str(spec.path.value)}
54
+ if command == "read-multiple-files":
55
+ return {"paths": [str(p) for p in spec.paths.value]}
56
+ if command == "list-directory":
57
+ return {"path": str(spec.path.value)}
58
+ if command == "list-directory-with-sizes":
59
+ return {"path": str(spec.path.value), "sort_by": spec.sort_by.value}
60
+ if command == "directory-tree":
61
+ return {"path": str(spec.path.value), "exclude": spec.exclude.value or None}
62
+ if command == "search-files":
63
+ return {"path": str(spec.path.value), "pattern": spec.pattern.value, "exclude": spec.exclude.value or None}
64
+ if command == "get-file-info":
65
+ return {"path": str(spec.path.value)}
66
+ if command == "list-allowed-directories":
67
+ return {}
68
+ if command == "write-file":
69
+ return {"path": str(spec.path.value), "content": spec.content.value}
70
+ if command == "edit-file":
71
+ return {
72
+ "path": str(spec.path.value),
73
+ "edits": _pair_edits(spec.old_text.value, spec.new_text.value),
74
+ "dry_run": spec.dry_run.value,
75
+ }
76
+ if command == "create-directory":
77
+ return {"path": str(spec.path.value)}
78
+ if command == "move-file":
79
+ return {"source": str(spec.source.value), "destination": str(spec.destination.value)}
80
+
81
+ # require-command = true makes a bare invocation unreachable.
82
+ raise SystemExit("no command — see `fs --help`") # pragma: no cover
83
+
84
+
85
+ def _run_as_worker(run_as: str, method: str, verb: str, kwargs: dict[str, Any]) -> str:
86
+ """Re-invoke the verb as ``run_as`` via the worker under sudo/su and return
87
+ its stdout. Raises :class:`BecomeError` on an escalation failure (a
88
+ passwordless-sudo hint on the classic cases)."""
89
+ if not escalation_supported():
90
+ raise BecomeError("--run-as requires a POSIX host (sudo/su)")
91
+ argv = build_become([sys.executable, "-m", "runspec_linux.fs._worker"], run_as, method)
92
+ payload = json.dumps({"verb": verb, "kwargs": kwargs})
93
+ try:
94
+ proc = subprocess.run(argv, input=payload, capture_output=True, text=True)
95
+ except FileNotFoundError as exc: # sudo / su not on PATH
96
+ raise BecomeError(f"{method} not found: {exc}") from exc
97
+
98
+ if proc.returncode != 0:
99
+ err = (proc.stderr or "").strip()
100
+ low = err.lower()
101
+ if "password is required" in low or "a terminal is required" in low:
102
+ raise BecomeError(f"passwordless {method} is not configured for run-as {run_as!r} ({err})")
103
+ # The worker prints a JSON error on stdout for tool-level failures — pass
104
+ # it through with the worker's exit code rather than masking it.
105
+ out = (proc.stdout or "").strip()
106
+ if out:
107
+ print(out)
108
+ sys.exit(proc.returncode)
109
+ raise BecomeError(err or f"run-as {run_as!r} failed (exit {proc.returncode})")
110
+ return proc.stdout
111
+
112
+
113
+ def main() -> None:
114
+ spec = rs.parse("fs")
115
+ run_as = spec.run_as.value
116
+ method = spec.become_method.value
117
+ try:
118
+ kwargs = _verb_kwargs(spec)
119
+ if run_as and run_as != current_user():
120
+ out = _run_as_worker(run_as, method, spec.runspec_command, kwargs)
121
+ sys.stdout.write(out if out.endswith("\n") else out + "\n")
122
+ return
123
+ result = run_verb(spec.runspec_command, kwargs)
124
+ except (FsCoreError, OSError, ValueError) as exc:
125
+ _emit({"error": str(exc), "kind": type(exc).__name__})
126
+ sys.exit(1)
127
+ _emit(result)
128
+
129
+
130
+ if __name__ == "__main__":
131
+ main()
@@ -0,0 +1,50 @@
1
+ """Wrappers for the application discovery & investigation runnables."""
2
+
3
+ import json
4
+ import sys
5
+
6
+ import runspec as rs
7
+ from runspec_linux_core import failed_units, open_ports, owning_package, service_detail
8
+
9
+ from runspec_linux._escalation import resolve_escalation
10
+
11
+
12
+ def main_service_detail() -> None:
13
+ spec = rs.parse("service-detail")
14
+ unit = spec.unit.value
15
+ run_as, method = resolve_escalation(spec)
16
+ try:
17
+ print(json.dumps(service_detail(unit, run_as=run_as, become_method=method)))
18
+ except Exception as e:
19
+ print(json.dumps({"error": str(e), "unit": unit}))
20
+ sys.exit(1)
21
+
22
+
23
+ def main_open_ports() -> None:
24
+ spec = rs.parse("open-ports")
25
+ proto = spec.proto.value
26
+ run_as, method = resolve_escalation(spec)
27
+ try:
28
+ print(json.dumps(open_ports(proto, run_as=run_as, become_method=method)))
29
+ except Exception as e:
30
+ print(json.dumps({"error": str(e)}))
31
+ sys.exit(1)
32
+
33
+
34
+ def main_owning_package() -> None:
35
+ spec = rs.parse("owning-package")
36
+ path = str(spec.path.value)
37
+ try:
38
+ print(json.dumps(owning_package(path)))
39
+ except Exception as e:
40
+ print(json.dumps({"error": str(e), "path": path}))
41
+ sys.exit(1)
42
+
43
+
44
+ def main_failed_units() -> None:
45
+ rs.parse("failed-units")
46
+ try:
47
+ print(json.dumps(failed_units()))
48
+ except Exception as e:
49
+ print(json.dumps({"error": str(e)}))
50
+ sys.exit(1)
@@ -5,15 +5,21 @@ import sys
5
5
 
6
6
  import runspec as rs
7
7
  from runspec_linux_core import journalctl, search_log, tail_log
8
- from runspec_linux_core.errors import ToolNotFoundError
8
+ from runspec_linux_core.errors import CommandError, ToolNotFoundError
9
+
10
+ from runspec_linux._escalation import resolve_escalation
9
11
 
10
12
 
11
13
  def main_tail_log() -> None:
12
14
  spec = rs.parse("tail-log")
13
15
  file_path = str(spec.file.value)
14
16
  lines = spec.lines.value
17
+ run_as, method = resolve_escalation(spec)
15
18
  try:
16
- print(json.dumps(tail_log(file_path, lines)))
19
+ print(json.dumps(tail_log(file_path, lines, run_as=run_as, become_method=method)))
20
+ except CommandError as e:
21
+ print(json.dumps({"error": str(e), "file": file_path}))
22
+ sys.exit(1)
17
23
  except OSError as e:
18
24
  print(json.dumps({"error": str(e), "file": file_path}))
19
25
  sys.exit(1)
@@ -24,8 +30,12 @@ def main_search_log() -> None:
24
30
  file_path = str(spec.file.value)
25
31
  pattern = spec.pattern.value
26
32
  limit = spec.limit.value
33
+ run_as, method = resolve_escalation(spec)
27
34
  try:
28
- print(json.dumps(search_log(file_path, pattern, limit)))
35
+ print(json.dumps(search_log(file_path, pattern, limit, run_as=run_as, become_method=method)))
36
+ except CommandError as e:
37
+ print(json.dumps({"error": str(e), "file": file_path}))
38
+ sys.exit(1)
29
39
  except OSError as e:
30
40
  print(json.dumps({"error": str(e), "file": file_path}))
31
41
  sys.exit(1)
@@ -39,8 +49,9 @@ def main_journalctl() -> None:
39
49
  unit = spec.unit.value
40
50
  lines = spec.lines.value
41
51
  since = spec.since.value
52
+ run_as, method = resolve_escalation(spec)
42
53
  try:
43
- print(json.dumps(journalctl(unit, lines, since)))
54
+ print(json.dumps(journalctl(unit, lines, since, run_as=run_as, become_method=method)))
44
55
  except ToolNotFoundError as e:
45
56
  print(json.dumps({"error": str(e)}))
46
57
  sys.exit(1)
@@ -0,0 +1,80 @@
1
+ import json
2
+ import sys
3
+
4
+ import runspec as rs
5
+ from runspec_linux_core import (
6
+ check_port,
7
+ dns_config,
8
+ ping_host,
9
+ route_table,
10
+ show_connections,
11
+ socket_stats,
12
+ trace_route,
13
+ )
14
+
15
+
16
+ def main_ping_host() -> None:
17
+ spec = rs.parse("ping-host")
18
+ host = spec.host.value
19
+ count = spec.count.value
20
+ try:
21
+ print(json.dumps(ping_host(host, count)))
22
+ except Exception as e:
23
+ print(json.dumps({"error": str(e), "host": host}))
24
+ sys.exit(1)
25
+
26
+
27
+ def main_check_port() -> None:
28
+ spec = rs.parse("check-port")
29
+ host = spec.host.value
30
+ port = spec.port.value
31
+ timeout = spec.timeout.value
32
+ print(json.dumps(check_port(host, port, timeout)))
33
+
34
+
35
+ def main_show_connections() -> None:
36
+ spec = rs.parse("show-connections")
37
+ state_filter = spec.state.value
38
+ try:
39
+ print(json.dumps(show_connections(state_filter)))
40
+ except Exception as e:
41
+ print(json.dumps({"error": str(e)}))
42
+ sys.exit(1)
43
+
44
+
45
+ def main_trace_route() -> None:
46
+ spec = rs.parse("trace-route")
47
+ host = spec.host.value
48
+ max_hops = spec.max_hops.value
49
+ try:
50
+ print(json.dumps(trace_route(host, max_hops)))
51
+ except Exception as e:
52
+ print(json.dumps({"error": str(e), "host": host}))
53
+ sys.exit(1)
54
+
55
+
56
+ def main_route_table() -> None:
57
+ rs.parse("route-table")
58
+ try:
59
+ print(json.dumps(route_table()))
60
+ except Exception as e:
61
+ print(json.dumps({"error": str(e)}))
62
+ sys.exit(1)
63
+
64
+
65
+ def main_socket_stats() -> None:
66
+ rs.parse("socket-stats")
67
+ try:
68
+ print(json.dumps(socket_stats()))
69
+ except Exception as e:
70
+ print(json.dumps({"error": str(e)}))
71
+ sys.exit(1)
72
+
73
+
74
+ def main_dns_config() -> None:
75
+ rs.parse("dns-config")
76
+ try:
77
+ print(json.dumps(dns_config()))
78
+ except Exception as e:
79
+ print(json.dumps({"error": str(e)}))
80
+ sys.exit(1)
@@ -0,0 +1,54 @@
1
+ """Wrappers for the performance tuning & analysis runnables."""
2
+
3
+ import json
4
+ import sys
5
+
6
+ import runspec as rs
7
+ from runspec_linux_core import cpu_info, get_sysctl, pressure, set_sysctl
8
+ from runspec_linux_core.errors import CommandError
9
+
10
+ from runspec_linux._escalation import resolve_escalation
11
+
12
+
13
+ def main_get_sysctl() -> None:
14
+ spec = rs.parse("get-sysctl")
15
+ pattern = spec.pattern.value or None
16
+ try:
17
+ print(json.dumps(get_sysctl(pattern)))
18
+ except Exception as e:
19
+ print(json.dumps({"error": str(e)}))
20
+ sys.exit(1)
21
+
22
+
23
+ def main_set_sysctl() -> None:
24
+ spec = rs.parse("set-sysctl")
25
+ key = spec.key.value
26
+ value = spec.value.value
27
+ persist = spec.persist.value
28
+ run_as, method = resolve_escalation(spec)
29
+ try:
30
+ print(json.dumps(set_sysctl(key, value, persist=persist, run_as=run_as, become_method=method)))
31
+ except CommandError as e:
32
+ print(json.dumps({"key": key, "applied": False, "error": str(e)}))
33
+ sys.exit(1)
34
+ except Exception as e:
35
+ print(json.dumps({"error": str(e)}))
36
+ sys.exit(1)
37
+
38
+
39
+ def main_cpu_info() -> None:
40
+ rs.parse("cpu-info")
41
+ try:
42
+ print(json.dumps(cpu_info()))
43
+ except Exception as e:
44
+ print(json.dumps({"error": str(e)}))
45
+ sys.exit(1)
46
+
47
+
48
+ def main_pressure() -> None:
49
+ rs.parse("pressure")
50
+ try:
51
+ print(json.dumps(pressure()))
52
+ except Exception as e:
53
+ print(json.dumps({"error": str(e)}))
54
+ sys.exit(1)
@@ -81,6 +81,27 @@ type = "str"
81
81
  short = "-s"
82
82
  description = "Service name to restart"
83
83
 
84
+ # Escalation: restarting a system service needs privilege.
85
+ [restart-service.args.run-as]
86
+ type = "str"
87
+ description = "Run as this specific OS user (via sudo -n -u / su). Mutually exclusive with --sudo."
88
+ required = false
89
+
90
+ [restart-service.args.sudo]
91
+ type = "flag"
92
+ description = "Run as root (via sudo). Mutually exclusive with --run-as."
93
+ default = false
94
+
95
+ [restart-service.args.become-method]
96
+ type = "choice"
97
+ options = ["sudo", "su"]
98
+ description = "How to escalate for --run-as/--sudo: sudo (passwordless, -n) or su."
99
+ default = "sudo"
100
+
101
+ [restart-service.groups.escalation]
102
+ exclusive = true
103
+ args = ["run-as", "sudo"]
104
+
84
105
  # ── Logs ──────────────────────────────────────────────────────────────────────
85
106
 
86
107
  [tail-log]
@@ -100,6 +121,27 @@ short = "-n"
100
121
  description = "Number of lines to return"
101
122
  default = 50
102
123
 
124
+ # Escalation: read a root-owned / service-account-owned log (tail -n under sudo/su).
125
+ [tail-log.args.run-as]
126
+ type = "str"
127
+ description = "Read as this specific OS user (via sudo -n -u / su). Mutually exclusive with --sudo."
128
+ required = false
129
+
130
+ [tail-log.args.sudo]
131
+ type = "flag"
132
+ description = "Read as root (via sudo). Mutually exclusive with --run-as."
133
+ default = false
134
+
135
+ [tail-log.args.become-method]
136
+ type = "choice"
137
+ options = ["sudo", "su"]
138
+ description = "How to escalate for --run-as/--sudo: sudo (passwordless, -n) or su."
139
+ default = "sudo"
140
+
141
+ [tail-log.groups.escalation]
142
+ exclusive = true
143
+ args = ["run-as", "sudo"]
144
+
103
145
  [search-log]
104
146
  serve = ["local"]
105
147
  description = "Search a log file for lines matching a pattern"
@@ -122,6 +164,27 @@ short = "-n"
122
164
  description = "Maximum number of matching lines to return"
123
165
  default = 100
124
166
 
167
+ # Escalation: search a root-owned / service-account-owned log (cat under sudo/su).
168
+ [search-log.args.run-as]
169
+ type = "str"
170
+ description = "Read as this specific OS user (via sudo -n -u / su). Mutually exclusive with --sudo."
171
+ required = false
172
+
173
+ [search-log.args.sudo]
174
+ type = "flag"
175
+ description = "Read as root (via sudo). Mutually exclusive with --run-as."
176
+ default = false
177
+
178
+ [search-log.args.become-method]
179
+ type = "choice"
180
+ options = ["sudo", "su"]
181
+ description = "How to escalate for --run-as/--sudo: sudo (passwordless, -n) or su."
182
+ default = "sudo"
183
+
184
+ [search-log.groups.escalation]
185
+ exclusive = true
186
+ args = ["run-as", "sudo"]
187
+
125
188
  [journalctl]
126
189
  serve = ["local"]
127
190
  description = "Fetch recent journal entries for a systemd unit (systemd-only)"
@@ -144,6 +207,27 @@ type = "str"
144
207
  description = "Return entries since this time string (e.g. '1 hour ago', '2026-05-27 08:00')"
145
208
  required = false
146
209
 
210
+ # Escalation: read a protected unit's journal (outside the systemd-journal group).
211
+ [journalctl.args.run-as]
212
+ type = "str"
213
+ description = "Read as this specific OS user (via sudo -n -u / su). Mutually exclusive with --sudo."
214
+ required = false
215
+
216
+ [journalctl.args.sudo]
217
+ type = "flag"
218
+ description = "Read as root (via sudo). Mutually exclusive with --run-as."
219
+ default = false
220
+
221
+ [journalctl.args.become-method]
222
+ type = "choice"
223
+ options = ["sudo", "su"]
224
+ description = "How to escalate for --run-as/--sudo: sudo (passwordless, -n) or su."
225
+ default = "sudo"
226
+
227
+ [journalctl.groups.escalation]
228
+ exclusive = true
229
+ args = ["run-as", "sudo"]
230
+
147
231
  # ── Network ───────────────────────────────────────────────────────────────────
148
232
 
149
233
  [ping-host]
@@ -896,3 +980,311 @@ type = "flag"
896
980
  short = "-k"
897
981
  description = "Skip TLS certificate verification when fetching the spec"
898
982
  default = false
983
+
984
+ # ── Performance tuning & analysis ─────────────────────────────────────────────
985
+
986
+ [get-sysctl]
987
+ serve = ["local"]
988
+ description = "Read kernel parameters (sysctl), optionally filtered by a substring of the key"
989
+ autonomy = "autonomous"
990
+ output = "json"
991
+
992
+ [get-sysctl.args.pattern]
993
+ type = "str"
994
+ short = "-p"
995
+ description = "Only return keys containing this substring (e.g. 'net.core')"
996
+ required = false
997
+
998
+ [set-sysctl]
999
+ serve = ["local"]
1000
+ description = "Set a kernel parameter (sysctl -w), optionally persisting it under /etc/sysctl.d"
1001
+ autonomy = "confirm"
1002
+ output = "json"
1003
+
1004
+ [set-sysctl.args.key]
1005
+ type = "str"
1006
+ short = "-k"
1007
+ description = "Dotted sysctl key (e.g. net.core.somaxconn)"
1008
+
1009
+ [set-sysctl.args.value]
1010
+ type = "str"
1011
+ description = "New value to set"
1012
+
1013
+ [set-sysctl.args.persist]
1014
+ type = "flag"
1015
+ description = "Also append the setting to /etc/sysctl.d/99-runspec.conf so it survives a reboot"
1016
+ default = false
1017
+
1018
+ # Escalation: writing a kernel parameter is privileged.
1019
+ [set-sysctl.args.run-as]
1020
+ type = "str"
1021
+ description = "Apply as this specific OS user (via sudo -n -u / su). Mutually exclusive with --sudo."
1022
+ required = false
1023
+
1024
+ [set-sysctl.args.sudo]
1025
+ type = "flag"
1026
+ description = "Apply as root (via sudo). Mutually exclusive with --run-as."
1027
+ default = false
1028
+
1029
+ [set-sysctl.args.become-method]
1030
+ type = "choice"
1031
+ options = ["sudo", "su"]
1032
+ description = "How to escalate for --run-as/--sudo: sudo (passwordless, -n) or su."
1033
+ default = "sudo"
1034
+
1035
+ [set-sysctl.groups.escalation]
1036
+ exclusive = true
1037
+ args = ["run-as", "sudo"]
1038
+
1039
+ [cpu-info]
1040
+ serve = ["local"]
1041
+ description = "Report CPU model, core count, and per-CPU scaling governor and frequency"
1042
+ autonomy = "autonomous"
1043
+ output = "json"
1044
+
1045
+ [pressure]
1046
+ serve = ["local"]
1047
+ description = "Report kernel PSI pressure-stall stats for CPU, IO, and memory"
1048
+ autonomy = "autonomous"
1049
+ output = "json"
1050
+
1051
+ # ── Application discovery & investigation ─────────────────────────────────────
1052
+
1053
+ [service-detail]
1054
+ serve = ["local"]
1055
+ description = "Show where a systemd unit lives: ExecStart binary, working dir, user, env files, unit-file path, and state"
1056
+ autonomy = "autonomous"
1057
+ output = "json"
1058
+
1059
+ [service-detail.args.unit]
1060
+ type = "str"
1061
+ short = "-u"
1062
+ description = "Systemd unit name (e.g. nginx or nginx.service)"
1063
+
1064
+ # Escalation: some properties on protected units need privilege.
1065
+ [service-detail.args.run-as]
1066
+ type = "str"
1067
+ description = "Read as this specific OS user (via sudo -n -u / su). Mutually exclusive with --sudo."
1068
+ required = false
1069
+
1070
+ [service-detail.args.sudo]
1071
+ type = "flag"
1072
+ description = "Read as root (via sudo). Mutually exclusive with --run-as."
1073
+ default = false
1074
+
1075
+ [service-detail.args.become-method]
1076
+ type = "choice"
1077
+ options = ["sudo", "su"]
1078
+ description = "How to escalate for --run-as/--sudo: sudo (passwordless, -n) or su."
1079
+ default = "sudo"
1080
+
1081
+ [service-detail.groups.escalation]
1082
+ exclusive = true
1083
+ args = ["run-as", "sudo"]
1084
+
1085
+ [open-ports]
1086
+ serve = ["local"]
1087
+ description = "List listening sockets mapped to the owning process (port to pid to process name)"
1088
+ autonomy = "autonomous"
1089
+ output = "json"
1090
+
1091
+ [open-ports.args.proto]
1092
+ type = "choice"
1093
+ options = ["tcp", "udp", "both"]
1094
+ description = "Which listening sockets to report"
1095
+ default = "tcp"
1096
+
1097
+ # Escalation: the full pid/process map needs privilege (ss shows other users' processes only as root).
1098
+ [open-ports.args.run-as]
1099
+ type = "str"
1100
+ description = "Probe as this specific OS user (via sudo -n -u / su). Mutually exclusive with --sudo."
1101
+ required = false
1102
+
1103
+ [open-ports.args.sudo]
1104
+ type = "flag"
1105
+ description = "Probe as root (via sudo). Mutually exclusive with --run-as."
1106
+ default = false
1107
+
1108
+ [open-ports.args.become-method]
1109
+ type = "choice"
1110
+ options = ["sudo", "su"]
1111
+ description = "How to escalate for --run-as/--sudo: sudo (passwordless, -n) or su."
1112
+ default = "sudo"
1113
+
1114
+ [open-ports.groups.escalation]
1115
+ exclusive = true
1116
+ args = ["run-as", "sudo"]
1117
+
1118
+ [owning-package]
1119
+ serve = ["local"]
1120
+ description = "Report which installed package owns a file path (dpkg / rpm)"
1121
+ autonomy = "autonomous"
1122
+ output = "json"
1123
+
1124
+ [owning-package.args.path]
1125
+ type = "path"
1126
+ short = "-p"
1127
+ description = "File path to resolve to its owning package"
1128
+
1129
+ [failed-units]
1130
+ serve = ["local"]
1131
+ description = "List systemd service units currently in the failed state"
1132
+ autonomy = "autonomous"
1133
+ output = "json"
1134
+
1135
+ # ── Network deep-diagnostics ──────────────────────────────────────────────────
1136
+
1137
+ [trace-route]
1138
+ serve = ["local"]
1139
+ description = "Trace the network path to a host (traceroute / tracepath)"
1140
+ autonomy = "autonomous"
1141
+ output = "json"
1142
+
1143
+ [trace-route.args.host]
1144
+ type = "str"
1145
+ short = "-H"
1146
+ description = "Destination host or IP"
1147
+
1148
+ [trace-route.args.max-hops]
1149
+ type = "int"
1150
+ short = "-m"
1151
+ description = "Maximum number of hops to probe"
1152
+ default = 30
1153
+
1154
+ [route-table]
1155
+ serve = ["local"]
1156
+ description = "Show the kernel IPv4 routing table (ip route)"
1157
+ autonomy = "autonomous"
1158
+ output = "json"
1159
+
1160
+ [socket-stats]
1161
+ serve = ["local"]
1162
+ description = "Summarise socket usage by protocol (ss -s)"
1163
+ autonomy = "autonomous"
1164
+ output = "json"
1165
+
1166
+ [dns-config]
1167
+ serve = ["local"]
1168
+ description = "Show the host's DNS configuration: nameservers, search domains, and options (/etc/resolv.conf + resolvectl)"
1169
+ autonomy = "autonomous"
1170
+ output = "json"
1171
+
1172
+ # ── Filesystem (folded from runspec-fs; backed by runspec-fs-core) ─────────────
1173
+ # The filesystem is part of the OS, so the Linux pack ships the `fs` runnable
1174
+ # directly. One verb per Filesystem MCP tool; `runspec serve` re-expands each
1175
+ # verb into a leaf MCP tool (fs_read-text-file, fs_write-file, …), giving a
1176
+ # runspec-console agent local AND remote filesystem access on every fleet host.
1177
+ # No directory jail — access is bounded by the OS permissions of the invoking
1178
+ # (or --run-as) user.
1179
+
1180
+ [fs]
1181
+ serve = ["local"]
1182
+ description = "Read and manipulate the filesystem on this host (local, or remote via runspec-console)"
1183
+ output = "json"
1184
+ require-command = true
1185
+ examples = [
1186
+ {cmd = "fs list-directory --path .", description = "List the current directory"},
1187
+ {cmd = "fs read-text-file --path README.md --head 20", description = "Read the first 20 lines of a file"},
1188
+ {cmd = "fs search-files --path . --pattern '*.py'", description = "Find Python files recursively"},
1189
+ {cmd = "fs edit-file --path a.txt --old-text foo --new-text bar", description = "Replace 'foo' with 'bar'"},
1190
+ {cmd = "fs --run-as deploy read-text-file --path /home/deploy/app.log", description = "Run a verb as another OS user (chosen per invocation)"},
1191
+ ]
1192
+
1193
+ # ── global args (inherited by every verb) ──────────────────────────────────
1194
+ # Per-invocation run-as: the user/agent picks which OS user each verb runs as,
1195
+ # like runspec-linux's venv runnables. The operation is re-invoked as that user
1196
+ # via sudo/su, so access is bounded by THAT user's OS permissions — no jail.
1197
+ [fs.args]
1198
+ run-as = {type = "str", required = false, description = "Run this operation as another OS user (via sudo -n -u / su, chosen per invocation). Access is then bounded by that user's OS permissions. POSIX only; needs passwordless sudo for the target user."}
1199
+ become-method = {type = "choice", options = ["sudo", "su"], default = "sudo", description = "How to escalate to --run-as."}
1200
+
1201
+ # ── reads / metadata → autonomous ──────────────────────────────────────────
1202
+
1203
+ [fs.commands.read-text-file]
1204
+ description = "Read a file as UTF-8 text; optionally only the first N (--head) or last N (--tail) lines"
1205
+ autonomy = "autonomous"
1206
+ [fs.commands.read-text-file.args]
1207
+ path = {type = "path", required = true, description = "File to read"}
1208
+ head = {type = "int", required = false, description = "Return only the first N lines"}
1209
+ tail = {type = "int", required = false, description = "Return only the last N lines"}
1210
+
1211
+ [fs.commands.read-media-file]
1212
+ description = "Read a binary/media file and return it base64-encoded with its MIME type"
1213
+ autonomy = "autonomous"
1214
+ [fs.commands.read-media-file.args]
1215
+ path = {type = "path", required = true, description = "File to read"}
1216
+
1217
+ [fs.commands.read-multiple-files]
1218
+ description = "Read several files at once; a failed read is reported per-file without aborting the batch"
1219
+ autonomy = "autonomous"
1220
+ [fs.commands.read-multiple-files.args]
1221
+ paths = {type = "path", multiple = true, required = true, description = "Files to read (repeat --paths per file)"}
1222
+
1223
+ [fs.commands.list-directory]
1224
+ description = "List a directory's immediate contents, each tagged file or directory"
1225
+ autonomy = "autonomous"
1226
+ [fs.commands.list-directory.args]
1227
+ path = {type = "path", required = true, description = "Directory to list"}
1228
+
1229
+ [fs.commands.list-directory-with-sizes]
1230
+ description = "List a directory's contents with per-entry sizes and totals"
1231
+ autonomy = "autonomous"
1232
+ [fs.commands.list-directory-with-sizes.args]
1233
+ path = {type = "path", required = true, description = "Directory to list"}
1234
+ sort-by = {type = "choice", options = ["name", "size"], default = "name", description = "Sort order (size is descending)"}
1235
+
1236
+ [fs.commands.directory-tree]
1237
+ description = "Return a recursive JSON tree of a directory"
1238
+ autonomy = "autonomous"
1239
+ [fs.commands.directory-tree.args]
1240
+ path = {type = "path", required = true, description = "Directory to walk"}
1241
+ exclude = {type = "str", multiple = true, required = false, description = "Glob patterns to prune (repeat --exclude)"}
1242
+
1243
+ [fs.commands.search-files]
1244
+ description = "Recursively find files/directories whose name matches a pattern (glob, or case-insensitive substring)"
1245
+ autonomy = "autonomous"
1246
+ [fs.commands.search-files.args]
1247
+ path = {type = "path", required = true, description = "Directory to search under"}
1248
+ pattern = {type = "str", required = true, description = "Glob (e.g. '*.py') or a case-insensitive substring"}
1249
+ exclude = {type = "str", multiple = true, required = false, description = "Glob patterns to prune (repeat --exclude)"}
1250
+
1251
+ [fs.commands.get-file-info]
1252
+ description = "Return detailed metadata for a file or directory (size, timestamps, permissions)"
1253
+ autonomy = "autonomous"
1254
+ [fs.commands.get-file-info.args]
1255
+ path = {type = "path", required = true, description = "File or directory to stat"}
1256
+
1257
+ [fs.commands.list-allowed-directories]
1258
+ description = "Report the effective user and starting directories (informational — no directory jail is enforced)"
1259
+ autonomy = "autonomous"
1260
+
1261
+ # ── mutations → confirm ────────────────────────────────────────────────────
1262
+
1263
+ [fs.commands.write-file]
1264
+ description = "Create a new file or overwrite an existing one with UTF-8 text"
1265
+ autonomy = "confirm"
1266
+ [fs.commands.write-file.args]
1267
+ path = {type = "path", required = true, description = "File to write"}
1268
+ content = {type = "str", required = true, description = "File contents"}
1269
+
1270
+ [fs.commands.edit-file]
1271
+ description = "Apply ordered old→new substring replacements to a file; returns a unified diff. Pair each --old-text with a --new-text by position."
1272
+ autonomy = "confirm"
1273
+ [fs.commands.edit-file.args]
1274
+ path = {type = "path", required = true, description = "File to edit"}
1275
+ old-text = {type = "str", multiple = true, required = true, description = "Text to find (repeat --old-text; paired by position with --new-text)"}
1276
+ new-text = {type = "str", multiple = true, required = true, description = "Replacement text (repeat --new-text; paired by position with --old-text)"}
1277
+ dry-run = {type = "flag", default = false, description = "Compute and diff the edits without writing"}
1278
+
1279
+ [fs.commands.create-directory]
1280
+ description = "Create a directory (and any missing parents); idempotent"
1281
+ autonomy = "confirm"
1282
+ [fs.commands.create-directory.args]
1283
+ path = {type = "path", required = true, description = "Directory to create"}
1284
+
1285
+ [fs.commands.move-file]
1286
+ description = "Move or rename a file/directory; fails if the destination already exists"
1287
+ autonomy = "confirm"
1288
+ [fs.commands.move-file.args]
1289
+ source = {type = "path", required = true, description = "Path to move"}
1290
+ destination = {type = "path", required = true, description = "New path (must not already exist)"}
@@ -5,6 +5,8 @@ import runspec as rs
5
5
  from runspec_linux_core import check_service, list_services, restart_service
6
6
  from runspec_linux_core.errors import CommandError
7
7
 
8
+ from runspec_linux._escalation import resolve_escalation
9
+
8
10
 
9
11
  def main_check_service() -> None:
10
12
  spec = rs.parse("check-service")
@@ -29,8 +31,9 @@ def main_list_services() -> None:
29
31
  def main_restart_service() -> None:
30
32
  spec = rs.parse("restart-service")
31
33
  service = spec.service.value
34
+ run_as, method = resolve_escalation(spec)
32
35
  try:
33
- print(json.dumps(restart_service(service)))
36
+ print(json.dumps(restart_service(service, run_as=run_as, become_method=method)))
34
37
  except CommandError as e:
35
38
  print(json.dumps({"service": service, "restarted": False, "error": str(e)}))
36
39
  sys.exit(1)
@@ -0,0 +1,34 @@
1
+ """The wrapper-side escalation resolver: --sudo / --run-as / --become-method →
2
+ (run_as, become_method).
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from types import SimpleNamespace
8
+
9
+ from runspec_linux._escalation import resolve_escalation
10
+
11
+
12
+ def _spec(sudo: bool, run_as: str, method: str = "sudo") -> SimpleNamespace:
13
+ # A parsed spec exposes each arg as an object with a .value.
14
+ return SimpleNamespace(
15
+ sudo=SimpleNamespace(value=sudo),
16
+ run_as=SimpleNamespace(value=run_as),
17
+ become_method=SimpleNamespace(value=method),
18
+ )
19
+
20
+
21
+ def test_sudo_maps_to_root() -> None:
22
+ assert resolve_escalation(_spec(sudo=True, run_as="")) == ("root", "sudo")
23
+
24
+
25
+ def test_run_as_user() -> None:
26
+ assert resolve_escalation(_spec(sudo=False, run_as="deploy")) == ("deploy", "sudo")
27
+
28
+
29
+ def test_neither_is_none() -> None:
30
+ assert resolve_escalation(_spec(sudo=False, run_as="")) == (None, "sudo")
31
+
32
+
33
+ def test_become_method_passthrough() -> None:
34
+ assert resolve_escalation(_spec(sudo=False, run_as="svc", method="su")) == ("svc", "su")
@@ -0,0 +1,51 @@
1
+ """The `fs` runnable folded into runspec-linux (backed by runspec-fs-core).
2
+
3
+ Smoke coverage that the folded verbs run in-process and that the run-as worker
4
+ path builds the right escalation command. Full verb behaviour is covered by
5
+ runspec-fs-core's own suite.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import subprocess
12
+
13
+ import pytest
14
+
15
+ from runspec_linux.fs import _dispatch, cli
16
+
17
+
18
+ def test_dispatch_read_and_list(tmp_path) -> None:
19
+ f = tmp_path / "a.txt"
20
+ f.write_text("hello\nworld\n")
21
+ assert _dispatch.run_verb("read-text-file", {"path": str(f)})["content"] == "hello\nworld\n"
22
+ listing = _dispatch.run_verb("list-directory", {"path": str(tmp_path)})
23
+ assert listing["count"] == 1
24
+ assert listing["entries"][0]["name"] == "a.txt"
25
+
26
+
27
+ def test_dispatch_unknown_verb() -> None:
28
+ with pytest.raises(ValueError):
29
+ _dispatch.run_verb("frobnicate", {})
30
+
31
+
32
+ def test_pair_edits_mismatch() -> None:
33
+ with pytest.raises(ValueError, match="count mismatch"):
34
+ cli._pair_edits(["a", "b"], ["x"])
35
+
36
+
37
+ def test_run_as_worker_builds_sudo_command(monkeypatch: pytest.MonkeyPatch) -> None:
38
+ captured: dict[str, object] = {}
39
+
40
+ def fake_run(argv, input=None, capture_output=None, text=None): # noqa: A002
41
+ captured["argv"] = argv
42
+ captured["input"] = input
43
+ return subprocess.CompletedProcess(argv, 0, stdout='{"ok": true}', stderr="")
44
+
45
+ monkeypatch.setattr(cli.subprocess, "run", fake_run)
46
+ out = cli._run_as_worker("svc", "sudo", "list-directory", {"path": "/tmp"})
47
+ argv = captured["argv"]
48
+ assert argv[:4] == ["sudo", "-n", "-u", "svc"]
49
+ assert argv[-2:] == ["-m", "runspec_linux.fs._worker"]
50
+ assert json.loads(captured["input"]) == {"verb": "list-directory", "kwargs": {"path": "/tmp"}}
51
+ assert json.loads(out) == {"ok": True}
@@ -1,34 +0,0 @@
1
- import json
2
- import sys
3
-
4
- import runspec as rs
5
- from runspec_linux_core import check_port, ping_host, show_connections
6
-
7
-
8
- def main_ping_host() -> None:
9
- spec = rs.parse("ping-host")
10
- host = spec.host.value
11
- count = spec.count.value
12
- try:
13
- print(json.dumps(ping_host(host, count)))
14
- except Exception as e:
15
- print(json.dumps({"error": str(e), "host": host}))
16
- sys.exit(1)
17
-
18
-
19
- def main_check_port() -> None:
20
- spec = rs.parse("check-port")
21
- host = spec.host.value
22
- port = spec.port.value
23
- timeout = spec.timeout.value
24
- print(json.dumps(check_port(host, port, timeout)))
25
-
26
-
27
- def main_show_connections() -> None:
28
- spec = rs.parse("show-connections")
29
- state_filter = spec.state.value
30
- try:
31
- print(json.dumps(show_connections(state_filter)))
32
- except Exception as e:
33
- print(json.dumps({"error": str(e)}))
34
- sys.exit(1)
File without changes