conning 0.2.0__py3-none-any.whl
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.
- conning/__init__.py +3 -0
- conning/__main__.py +3 -0
- conning/admin.py +489 -0
- conning/askpass.py +9 -0
- conning/audit.py +126 -0
- conning/cli.py +263 -0
- conning/core.py +458 -0
- conning/doctor.py +105 -0
- conning/install.py +214 -0
- conning/notify.py +46 -0
- conning/output.py +77 -0
- conning/paths.py +29 -0
- conning/policy.py +143 -0
- conning/remote.py +267 -0
- conning/secrets.py +75 -0
- conning/server.py +153 -0
- conning/ssh.py +232 -0
- conning/state.py +150 -0
- conning/targets.py +169 -0
- conning/transport.py +74 -0
- conning/watch.py +70 -0
- conning-0.2.0.dist-info/METADATA +200 -0
- conning-0.2.0.dist-info/RECORD +26 -0
- conning-0.2.0.dist-info/WHEEL +4 -0
- conning-0.2.0.dist-info/entry_points.txt +3 -0
- conning-0.2.0.dist-info/licenses/LICENSE +202 -0
conning/cli.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""The `conn` command line. Humans manage targets here; agents with only a shell may use it too.
|
|
2
|
+
|
|
3
|
+
Every agent-facing command prints exactly what the matching MCP tool returns.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from conning import __version__, admin, doctor, install, watch
|
|
11
|
+
from conning.output import DEFAULT_MAX_BYTES, MAX_BYTES_CEILING
|
|
12
|
+
from conning.secrets import SecretError
|
|
13
|
+
from conning.ssh import SSHError
|
|
14
|
+
from conning.targets import AUTHS, MODES, TargetFileError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _core():
|
|
18
|
+
from conning.core import Core
|
|
19
|
+
|
|
20
|
+
return Core()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _io():
|
|
24
|
+
return admin.ConsoleIO()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _emit(text: str) -> int:
|
|
28
|
+
print(text)
|
|
29
|
+
return 1 if text.startswith("error:") else 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _run(args: argparse.Namespace) -> int:
|
|
33
|
+
if not args.argv:
|
|
34
|
+
print(f"error: give the command after the target, e.g. conn run {args.target} uname -a")
|
|
35
|
+
return 1
|
|
36
|
+
common = dict(cwd=args.cwd, wait=args.wait, timeout=args.timeout, max_bytes=args.max_bytes, grep=args.grep)
|
|
37
|
+
core, io = _core(), _io()
|
|
38
|
+
core.notify = not io.tty # a human at this terminal is asked right here, no pop-up needed
|
|
39
|
+
command = {"cmd": " ".join(args.argv)} if args.shell else {"argv": args.argv}
|
|
40
|
+
result = core.run(args.target, **command, **common)
|
|
41
|
+
return admin.run_here(core, io, result, wait=args.wait, max_bytes=args.max_bytes, grep=args.grep)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _add(a: argparse.Namespace) -> int:
|
|
45
|
+
return admin.add(
|
|
46
|
+
_core(), _io(), a.name, host=a.host, user=a.user, port=a.port, auth=a.auth, secret=a.secret, key=a.key,
|
|
47
|
+
os_name=a.os, mode=a.mode, tags=a.tag, retry=a.retry, like=a.like,
|
|
48
|
+
) # fmt: skip
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _watch(args: argparse.Namespace) -> int:
|
|
52
|
+
try:
|
|
53
|
+
return watch.watch(_core(), _io(), history=args.history, prompt=not args.no_prompt)
|
|
54
|
+
except KeyboardInterrupt:
|
|
55
|
+
return 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _mcp(args: argparse.Namespace) -> int:
|
|
59
|
+
if args.mcp_command in (None, "serve"):
|
|
60
|
+
from conning.server import build_server
|
|
61
|
+
|
|
62
|
+
build_server(approve_in_client=args.approve_in_client).run("stdio")
|
|
63
|
+
return 0
|
|
64
|
+
return install.install(_io(), args.client, dry_run=args.dry_run)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
HANDLERS = {
|
|
68
|
+
"targets": lambda a: _emit(_core().targets(a.filter, a.check)),
|
|
69
|
+
"run": _run,
|
|
70
|
+
"read": lambda a: _emit(
|
|
71
|
+
_core().read(a.id, wait=a.wait, range=a.range, grep=a.grep, signal=a.signal, max_bytes=a.max_bytes)
|
|
72
|
+
),
|
|
73
|
+
"transfer": lambda a: _emit(_core().transfer(a.source, a.dest)),
|
|
74
|
+
"add": _add,
|
|
75
|
+
"approve": lambda a: admin.approve(_core(), _io(), a.what, always=a.always),
|
|
76
|
+
"import": lambda a: admin.import_ssh_config(_io(), a.match),
|
|
77
|
+
"rm": lambda a: admin.remove(_io(), a.name),
|
|
78
|
+
"test": lambda a: admin.test(_core(), _io(), a.name),
|
|
79
|
+
"lease": lambda a: admin.lease(_io(), a.name, a.minutes),
|
|
80
|
+
"allow": lambda a: admin.allow(_io(), a.target, a.words),
|
|
81
|
+
"deny": lambda a: admin.deny(_io(), a.target, a.words),
|
|
82
|
+
"watch": _watch,
|
|
83
|
+
"doctor": lambda a: doctor.doctor(_io()),
|
|
84
|
+
"mcp": _mcp,
|
|
85
|
+
"help": lambda a: print(help_markdown() if a.markdown else build_parser().format_help()) or 0,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
90
|
+
p = argparse.ArgumentParser(prog="conn", description="Conning: a control room for your agent's test machines.")
|
|
91
|
+
p.add_argument("--version", action="version", version=f"conning {__version__}")
|
|
92
|
+
sub = p.add_subparsers(dest="command", metavar="<command>")
|
|
93
|
+
|
|
94
|
+
def cmd(name: str, help: str, aliases=()) -> argparse.ArgumentParser:
|
|
95
|
+
sp = sub.add_parser(name, help=help, description=help, aliases=list(aliases))
|
|
96
|
+
sp.set_defaults(func=HANDLERS[name])
|
|
97
|
+
return sp
|
|
98
|
+
|
|
99
|
+
# Agent-facing: same output as the MCP tools.
|
|
100
|
+
sp = cmd("targets", "List targets: name, OS, shell, user, tags, mode. Never hosts or secrets.", ["ls"])
|
|
101
|
+
sp.add_argument("filter", nargs="?", help="'tag:<tag>' or comma-separated names")
|
|
102
|
+
sp.add_argument("--check", action="store_true", help="probe reachability in parallel")
|
|
103
|
+
|
|
104
|
+
sp = cmd("run", "Run a command on one target, a list, or tag:<tag>. Exit code first.")
|
|
105
|
+
sp.add_argument("target")
|
|
106
|
+
sp.add_argument("--wait", type=int, default=30, help="seconds before returning a job id (0 = async)")
|
|
107
|
+
sp.add_argument("--timeout", type=int, default=3600, help="kill the command after this many seconds")
|
|
108
|
+
sp.add_argument("--cwd")
|
|
109
|
+
sp.add_argument("--shell", action="store_true", help="send the words as one shell string (pipes etc. always ask)")
|
|
110
|
+
sp.add_argument(
|
|
111
|
+
"--max-bytes",
|
|
112
|
+
type=int,
|
|
113
|
+
default=DEFAULT_MAX_BYTES,
|
|
114
|
+
help=f"output cap (default {DEFAULT_MAX_BYTES}, ceiling {MAX_BYTES_CEILING})",
|
|
115
|
+
)
|
|
116
|
+
sp.add_argument("--grep", help="keep only matching lines before capping")
|
|
117
|
+
sp.add_argument("argv", nargs="*", help="the command and its arguments (conn's own options go before the target)")
|
|
118
|
+
|
|
119
|
+
sp = cmd("read", "Page a job (new lines only), a saved output, an approval, or a remote file.")
|
|
120
|
+
sp.add_argument("id", help="j:<job>, o:<output>, a:<approval>, or <target>:<path>")
|
|
121
|
+
sp.add_argument("--wait", type=int, default=0, help="long-poll up to this many seconds")
|
|
122
|
+
sp.add_argument("--range", help="line range, e.g. 200-260 or -50 for the last 50")
|
|
123
|
+
sp.add_argument("--grep")
|
|
124
|
+
sp.add_argument("--signal", choices=["INT", "TERM", "KILL"])
|
|
125
|
+
sp.add_argument("--max-bytes", type=int, default=DEFAULT_MAX_BYTES)
|
|
126
|
+
|
|
127
|
+
sp = cmd("transfer", "Copy files: local:<path> to <target>:<path>, or back. sha256 checked.")
|
|
128
|
+
sp.add_argument("source")
|
|
129
|
+
sp.add_argument("dest")
|
|
130
|
+
|
|
131
|
+
# Target management. Without a terminal (an agent), a new target is saved as pending.
|
|
132
|
+
sp = cmd("add", "Add a target. Without a terminal it is saved as pending until a human approves it.")
|
|
133
|
+
sp.add_argument("name")
|
|
134
|
+
sp.add_argument("--host")
|
|
135
|
+
sp.add_argument("--user")
|
|
136
|
+
sp.add_argument("--like", metavar="TARGET", help="same machine as TARGET (host, port, OS, tags); pick --user")
|
|
137
|
+
sp.add_argument("--port", type=int, help="default 22")
|
|
138
|
+
sp.add_argument("--auth", choices=AUTHS)
|
|
139
|
+
sp.add_argument("--secret", help="reference only: keychain:<service>/<account>, op://..., env:<NAME>")
|
|
140
|
+
sp.add_argument("--key", help="private key file, for --auth key")
|
|
141
|
+
sp.add_argument("--os", choices=["linux", "windows", "darwin"])
|
|
142
|
+
sp.add_argument("--mode", choices=MODES, default="ask")
|
|
143
|
+
sp.add_argument("--tag", action="append", default=[])
|
|
144
|
+
sp.add_argument("--retry", action="store_true", help="probe an existing target again")
|
|
145
|
+
sp = cmd("approve", "Human only: make a pending target live, or allow a pending command a:<id>.")
|
|
146
|
+
sp.add_argument("what", help="<target> or a:<id>")
|
|
147
|
+
sp.add_argument("--always", action="store_true", help="for a:<id>: remember the command's first two words")
|
|
148
|
+
sp = cmd("import", "Import hosts from ~/.ssh/config as pending targets (unsafe options dropped).")
|
|
149
|
+
sp.add_argument("--match", default="*")
|
|
150
|
+
cmd("rm", "Remove a target and its pinned host key.").add_argument("name")
|
|
151
|
+
cmd("test", "Probe a target: reachability, OS, shell, pinned host key.").add_argument("name")
|
|
152
|
+
sp = cmd("lease", "Human only: give a target a full shell for a time box (1-60 minutes).")
|
|
153
|
+
sp.add_argument("name")
|
|
154
|
+
sp.add_argument("--minutes", type=int, default=15)
|
|
155
|
+
|
|
156
|
+
sp = cmd("allow", "Human only: let a command run without asking (no words: list the rules).")
|
|
157
|
+
sp.add_argument("target", help="a name, a comma list, or tag:<tag>")
|
|
158
|
+
sp.add_argument("words", nargs="*", help="the command's leading words, e.g. systemctl status")
|
|
159
|
+
sp = cmd("deny", "Remove an allow rule, so the command asks again.")
|
|
160
|
+
sp.add_argument("target", help="a name, a comma list, or tag:<tag>")
|
|
161
|
+
sp.add_argument("words", nargs="+")
|
|
162
|
+
|
|
163
|
+
# The human's live view.
|
|
164
|
+
sp = cmd("watch", "Live control room: every command on every target, job output, approval prompts.")
|
|
165
|
+
sp.add_argument("--history", type=int, default=10, help="show this many past events first")
|
|
166
|
+
sp.add_argument("--no-prompt", action="store_true", help="show activity only; never ask")
|
|
167
|
+
cmd("doctor", "Check this machine's setup: config and state dirs, ssh, keychain, targets.")
|
|
168
|
+
sp = cmd("mcp", "Run the MCP server on stdio, or install it into an agent client.")
|
|
169
|
+
sp.add_argument(
|
|
170
|
+
"--approve-in-client",
|
|
171
|
+
action=argparse.BooleanOptionalAction,
|
|
172
|
+
default=True,
|
|
173
|
+
help="ask the human in the agent client (MCP elicitation) when a command needs approval",
|
|
174
|
+
)
|
|
175
|
+
mcp_sub = sp.add_subparsers(dest="mcp_command", metavar="<serve|install>")
|
|
176
|
+
mcp_sub.add_parser("serve", help="run the MCP server on stdio (the default)")
|
|
177
|
+
sp = mcp_sub.add_parser("install", help="add Conning to an agent client, and deny it raw ssh/scp")
|
|
178
|
+
sp.add_argument("client", choices=list(install.PLANNERS))
|
|
179
|
+
sp.add_argument("--dry-run", action="store_true", help="show what would change")
|
|
180
|
+
|
|
181
|
+
sp = cmd("help", "Show this help; --markdown prints the block cached in README.md.")
|
|
182
|
+
sp.add_argument("--markdown", action="store_true")
|
|
183
|
+
return p
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def help_markdown() -> str:
|
|
187
|
+
"""The CLI reference agents read from the README instead of calling --help (decision D10)."""
|
|
188
|
+
columns = os.environ.get("COLUMNS")
|
|
189
|
+
os.environ["COLUMNS"] = "100" # stable wrapping, so the README copy can be checked in tests
|
|
190
|
+
try:
|
|
191
|
+
parser = build_parser()
|
|
192
|
+
return _help_markdown(parser)
|
|
193
|
+
finally:
|
|
194
|
+
if columns is None:
|
|
195
|
+
del os.environ["COLUMNS"]
|
|
196
|
+
else:
|
|
197
|
+
os.environ["COLUMNS"] = columns
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _help_markdown(parser: argparse.ArgumentParser) -> str:
|
|
201
|
+
sub = next(a for a in parser._actions if isinstance(a, argparse._SubParsersAction))
|
|
202
|
+
out = ["```"]
|
|
203
|
+
for action in sub._choices_actions:
|
|
204
|
+
sp = sub.choices[action.dest]
|
|
205
|
+
usage = sp.format_usage().strip().removeprefix("usage: ").replace(" [-h]", "")
|
|
206
|
+
out += [usage, f" {action.help}", ""]
|
|
207
|
+
out[-1] = "```"
|
|
208
|
+
return "\n".join(out)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
RUN_VALUE_OPTIONS = ("--wait", "--timeout", "--cwd", "--max-bytes", "--grep")
|
|
212
|
+
RUN_FLAGS = ("--shell",)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _run_options(argv: list[str], i: int) -> int:
|
|
216
|
+
"""Skip conn's own `run` options starting at argv[i]; return the index of the first other word."""
|
|
217
|
+
while i < len(argv):
|
|
218
|
+
word = argv[i]
|
|
219
|
+
if word in RUN_VALUE_OPTIONS:
|
|
220
|
+
i += 2
|
|
221
|
+
elif word in RUN_FLAGS or word.split("=", 1)[0] in RUN_VALUE_OPTIONS:
|
|
222
|
+
i += 1
|
|
223
|
+
else:
|
|
224
|
+
break
|
|
225
|
+
return i
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _split_remote_command(argv: list[str]) -> tuple[list[str], list[str] | None]:
|
|
229
|
+
"""Cut off the remote command so argparse never sees its flags.
|
|
230
|
+
|
|
231
|
+
`conn run [options] <target> [options] <command...>`: conn's own options may sit on either side of
|
|
232
|
+
the target; the first other word starts the remote command, and everything from there on is passed
|
|
233
|
+
through, flags included, so `conn run lab uname -a` works without `--`. An explicit `--` is honoured
|
|
234
|
+
too (and needed on Python 3.11, whose argparse mishandles `--` before a subcommand's positionals).
|
|
235
|
+
"""
|
|
236
|
+
if "--" in argv:
|
|
237
|
+
cut = argv.index("--")
|
|
238
|
+
return argv[:cut], argv[cut + 1 :]
|
|
239
|
+
if not argv or argv[0] != "run":
|
|
240
|
+
return argv, None
|
|
241
|
+
target = _run_options(argv, 1)
|
|
242
|
+
if target >= len(argv) or argv[target].startswith("-"):
|
|
243
|
+
return argv, None # no target (or -h): let argparse report it
|
|
244
|
+
command = _run_options(argv, target + 1)
|
|
245
|
+
return argv[:command], argv[command:]
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def main(argv: list[str] | None = None) -> int:
|
|
249
|
+
parser = build_parser()
|
|
250
|
+
argv, rest = _split_remote_command(list(sys.argv[1:] if argv is None else argv))
|
|
251
|
+
args = parser.parse_args(argv)
|
|
252
|
+
if rest is not None:
|
|
253
|
+
if getattr(args, "command", None) != "run":
|
|
254
|
+
parser.error("'--' is only used with conn run")
|
|
255
|
+
args.argv = [*args.argv, *rest]
|
|
256
|
+
if not getattr(args, "func", None):
|
|
257
|
+
parser.print_help()
|
|
258
|
+
return 0
|
|
259
|
+
try:
|
|
260
|
+
return args.func(args)
|
|
261
|
+
except (OSError, TargetFileError, SecretError, SSHError) as e: # one clear line, never a traceback
|
|
262
|
+
print(f"error: {e}")
|
|
263
|
+
return 1
|