hypha-debugger 0.1.11__tar.gz → 0.2.1__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.
- {hypha_debugger-0.1.11 → hypha_debugger-0.2.1}/PKG-INFO +1 -1
- {hypha_debugger-0.1.11 → hypha_debugger-0.2.1}/hypha_debugger/__init__.py +1 -1
- hypha_debugger-0.2.1/hypha_debugger/cli.py +563 -0
- {hypha_debugger-0.1.11 → hypha_debugger-0.2.1}/hypha_debugger/debugger.py +14 -24
- hypha_debugger-0.2.1/hypha_debugger/services/shell.py +154 -0
- {hypha_debugger-0.1.11 → hypha_debugger-0.2.1}/hypha_debugger/services/source.py +54 -0
- {hypha_debugger-0.1.11 → hypha_debugger-0.2.1}/hypha_debugger.egg-info/PKG-INFO +1 -1
- {hypha_debugger-0.1.11 → hypha_debugger-0.2.1}/hypha_debugger.egg-info/SOURCES.txt +5 -1
- {hypha_debugger-0.1.11 → hypha_debugger-0.2.1}/hypha_debugger.egg-info/entry_points.txt +1 -0
- {hypha_debugger-0.1.11 → hypha_debugger-0.2.1}/pyproject.toml +2 -1
- hypha_debugger-0.2.1/tests/test_cli.py +184 -0
- {hypha_debugger-0.1.11 → hypha_debugger-0.2.1}/tests/test_services.py +7 -9
- hypha_debugger-0.2.1/tests/test_shell.py +50 -0
- {hypha_debugger-0.1.11 → hypha_debugger-0.2.1}/README.md +0 -0
- {hypha_debugger-0.1.11 → hypha_debugger-0.2.1}/hypha_debugger/__main__.py +0 -0
- {hypha_debugger-0.1.11 → hypha_debugger-0.2.1}/hypha_debugger/services/__init__.py +0 -0
- {hypha_debugger-0.1.11 → hypha_debugger-0.2.1}/hypha_debugger/services/execute.py +0 -0
- {hypha_debugger-0.1.11 → hypha_debugger-0.2.1}/hypha_debugger/services/filesystem.py +0 -0
- {hypha_debugger-0.1.11 → hypha_debugger-0.2.1}/hypha_debugger/services/info.py +0 -0
- {hypha_debugger-0.1.11 → hypha_debugger-0.2.1}/hypha_debugger/services/inspect_vars.py +0 -0
- {hypha_debugger-0.1.11 → hypha_debugger-0.2.1}/hypha_debugger/utils/__init__.py +0 -0
- {hypha_debugger-0.1.11 → hypha_debugger-0.2.1}/hypha_debugger/utils/env.py +0 -0
- {hypha_debugger-0.1.11 → hypha_debugger-0.2.1}/hypha_debugger.egg-info/dependency_links.txt +0 -0
- {hypha_debugger-0.1.11 → hypha_debugger-0.2.1}/hypha_debugger.egg-info/requires.txt +0 -0
- {hypha_debugger-0.1.11 → hypha_debugger-0.2.1}/hypha_debugger.egg-info/top_level.txt +0 -0
- {hypha_debugger-0.1.11 → hypha_debugger-0.2.1}/setup.cfg +0 -0
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
"""``hyd`` — client CLI for the Hypha remote debugger.
|
|
2
|
+
|
|
3
|
+
Lets an agent (or a human) run remote shell commands with almost no per-call
|
|
4
|
+
overhead: connection profiles (URL + token) are stored once on disk, and each
|
|
5
|
+
call is just ``hyd sh 'cmd'`` (or the bare ``hyd 'cmd'``).
|
|
6
|
+
|
|
7
|
+
The remote is STATELESS. The "current session" — which profile is active and the
|
|
8
|
+
current directory — is held in **environment variables** (``HYD_PROFILE`` and
|
|
9
|
+
``HYD_CWD``), NOT on disk. Because env vars are per-shell, every terminal is
|
|
10
|
+
naturally isolated: switching terminals does not carry the active profile/cwd.
|
|
11
|
+
|
|
12
|
+
- ``profiles.json`` (disk, mode 0600) — the machines only: {profiles: {name: {service_url, token?, workspace?}}}
|
|
13
|
+
- ``HYD_PROFILE`` (env) — the active profile for this shell
|
|
14
|
+
- ``HYD_CWD`` (env) — the current remote directory for this shell
|
|
15
|
+
|
|
16
|
+
Commands that change the current state (``use``, ``cd``, ``profile add --use``)
|
|
17
|
+
print shell ``export`` lines (safe to ``eval``); ``eval "$(hyd shell-init)"``
|
|
18
|
+
installs a wrapper that applies them automatically and keeps ``HYD_CWD`` in sync
|
|
19
|
+
after every ``hyd sh``. See any debugger's ``GET <url>/get_skill_md``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import shlex
|
|
25
|
+
import sys
|
|
26
|
+
import urllib.error
|
|
27
|
+
import urllib.request
|
|
28
|
+
|
|
29
|
+
__all__ = ["main"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CliError(Exception):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---- config (profile DEFINITIONS only — never current-state) -------------
|
|
37
|
+
def _config_dir() -> str:
|
|
38
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
39
|
+
if xdg:
|
|
40
|
+
d = os.path.join(xdg, "hypha-debugger")
|
|
41
|
+
else:
|
|
42
|
+
legacy = os.path.expanduser("~/.hypha-debugger")
|
|
43
|
+
d = legacy if os.path.isdir(legacy) else os.path.expanduser("~/.config/hypha-debugger")
|
|
44
|
+
os.makedirs(d, exist_ok=True)
|
|
45
|
+
return d
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _profiles_path() -> str:
|
|
49
|
+
return os.path.join(_config_dir(), "profiles.json")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _load_profiles() -> dict:
|
|
53
|
+
try:
|
|
54
|
+
with open(_profiles_path()) as f:
|
|
55
|
+
return json.load(f).get("profiles", {})
|
|
56
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
57
|
+
return {}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _save_profiles(profiles: dict) -> None:
|
|
61
|
+
path = _profiles_path()
|
|
62
|
+
tmp = path + ".tmp"
|
|
63
|
+
with open(tmp, "w") as f:
|
|
64
|
+
json.dump({"profiles": profiles}, f, indent=2)
|
|
65
|
+
os.replace(tmp, path)
|
|
66
|
+
try:
|
|
67
|
+
os.chmod(path, 0o600)
|
|
68
|
+
except OSError:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---- current state = environment variables -------------------------------
|
|
73
|
+
def _env_profile() -> str:
|
|
74
|
+
return os.environ.get("HYD_PROFILE", "")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _env_cwd() -> str:
|
|
78
|
+
return os.environ.get("HYD_CWD", "")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _emit_state(exports: dict, human: str = "") -> None:
|
|
82
|
+
"""Apply env changes. Under the shell wrapper (`$HYD_STATE_FILE` set) write
|
|
83
|
+
export lines to that file for the parent shell to source; otherwise print
|
|
84
|
+
eval-safe output (a `# comment` for humans + the `export` lines)."""
|
|
85
|
+
lines = [f"export {k}={shlex.quote(str(v))}" for k, v in exports.items()]
|
|
86
|
+
sf = os.environ.get("HYD_STATE_FILE")
|
|
87
|
+
if sf:
|
|
88
|
+
with open(sf, "a") as f:
|
|
89
|
+
f.write("\n".join(lines) + "\n")
|
|
90
|
+
if human:
|
|
91
|
+
sys.stderr.write(human + "\n")
|
|
92
|
+
else:
|
|
93
|
+
if human:
|
|
94
|
+
print("# " + human)
|
|
95
|
+
for line in lines:
|
|
96
|
+
print(line)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _write_cwd(new_cwd: str) -> None:
|
|
100
|
+
"""After `hyd sh`, persist the new cwd to the shell (only via the wrapper's
|
|
101
|
+
state file — never to stdout, which carries the command output)."""
|
|
102
|
+
sf = os.environ.get("HYD_STATE_FILE")
|
|
103
|
+
if sf and new_cwd:
|
|
104
|
+
with open(sf, "a") as f:
|
|
105
|
+
f.write(f"export HYD_CWD={shlex.quote(new_cwd)}\n")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---- resolution ----------------------------------------------------------
|
|
109
|
+
def _normalize_url(url: str) -> str:
|
|
110
|
+
url = url.strip().rstrip("/")
|
|
111
|
+
for suffix in ("/get_skill_md", "/get_page_info"):
|
|
112
|
+
if url.endswith(suffix):
|
|
113
|
+
url = url[: -len(suffix)]
|
|
114
|
+
return url.rstrip("/")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---- profile type (terminal vs browser) ----------------------------------
|
|
118
|
+
def _profile_type(profile: dict) -> str:
|
|
119
|
+
return profile.get("type", "terminal")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _infer_type(url: str) -> str:
|
|
123
|
+
"""Guess the profile type from the service id in the URL."""
|
|
124
|
+
u = url.lower()
|
|
125
|
+
if "web-navigator" in u or "web-debugger" in u or "navigator" in u:
|
|
126
|
+
return "browser"
|
|
127
|
+
return "terminal"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _print_value(value) -> None:
|
|
131
|
+
"""Print a service result: strings verbatim, everything else as JSON."""
|
|
132
|
+
if value is None:
|
|
133
|
+
return
|
|
134
|
+
if isinstance(value, str):
|
|
135
|
+
sys.stdout.write(value if value.endswith("\n") else value + "\n")
|
|
136
|
+
else:
|
|
137
|
+
print(json.dumps(value, indent=2, default=str))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _resolve_profile(explicit: str = "") -> tuple:
|
|
141
|
+
"""Return (name, profile_dict). Priority: -p flag > $HYD_PROFILE > sole profile."""
|
|
142
|
+
profiles = _load_profiles()
|
|
143
|
+
if not profiles:
|
|
144
|
+
raise CliError("no profiles configured. Run: hyd profile add <name> <service_url> --use")
|
|
145
|
+
name = explicit or _env_profile()
|
|
146
|
+
if not name:
|
|
147
|
+
if len(profiles) == 1:
|
|
148
|
+
name = next(iter(profiles))
|
|
149
|
+
else:
|
|
150
|
+
raise CliError(
|
|
151
|
+
"no active profile. Set one: `export HYD_PROFILE=<name>` (or `eval \"$(hyd use <name>)\"`, "
|
|
152
|
+
f"or pass `-p <name>`). Profiles: {', '.join(profiles)}"
|
|
153
|
+
)
|
|
154
|
+
if name not in profiles:
|
|
155
|
+
raise CliError(f"unknown profile '{name}'. Profiles: {', '.join(profiles) or '(none)'}")
|
|
156
|
+
return name, profiles[name]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---- transport -----------------------------------------------------------
|
|
160
|
+
def _call_remote(profile: dict, fn: str, params: dict, http_timeout: int = 75) -> dict:
|
|
161
|
+
url = _normalize_url(profile["service_url"]) + f"/{fn}?_mode=last"
|
|
162
|
+
req = urllib.request.Request(url, data=json.dumps(params).encode(), method="POST")
|
|
163
|
+
req.add_header("Content-Type", "application/json")
|
|
164
|
+
if profile.get("token"):
|
|
165
|
+
req.add_header("Authorization", "Bearer " + profile["token"])
|
|
166
|
+
try:
|
|
167
|
+
with urllib.request.urlopen(req, timeout=http_timeout) as resp:
|
|
168
|
+
raw = resp.read().decode("utf-8", "replace")
|
|
169
|
+
except urllib.error.HTTPError as e:
|
|
170
|
+
body = e.read().decode("utf-8", "replace") if e.fp else ""
|
|
171
|
+
raise CliError(f"remote HTTP {e.code} calling {fn}: {body[:400]}")
|
|
172
|
+
except urllib.error.URLError as e:
|
|
173
|
+
raise CliError(f"cannot reach {url}: {e.reason}")
|
|
174
|
+
try:
|
|
175
|
+
data = json.loads(raw)
|
|
176
|
+
except json.JSONDecodeError:
|
|
177
|
+
return {"stdout": raw, "exit_code": 0, "cwd": params.get("cwd", "")}
|
|
178
|
+
if isinstance(data, dict) and "detail" in data and "stdout" not in data and "exit_code" not in data:
|
|
179
|
+
raise CliError(f"gateway error: {data['detail']} (is the debugger still connected?)")
|
|
180
|
+
return data
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ---- run -----------------------------------------------------------------
|
|
184
|
+
def _extract_opts(args: list) -> tuple:
|
|
185
|
+
"""Pull -p/--profile, -t/--timeout, --cwd out of args; return (opts, rest)."""
|
|
186
|
+
opts = {"profile": "", "timeout": None, "cwd": ""}
|
|
187
|
+
rest = []
|
|
188
|
+
i = 0
|
|
189
|
+
while i < len(args):
|
|
190
|
+
a = args[i]
|
|
191
|
+
if a in ("-p", "--profile") and i + 1 < len(args):
|
|
192
|
+
opts["profile"] = args[i + 1]; i += 2; continue
|
|
193
|
+
if a in ("-t", "--timeout") and i + 1 < len(args):
|
|
194
|
+
opts["timeout"] = int(args[i + 1]); i += 2; continue
|
|
195
|
+
if a == "--cwd" and i + 1 < len(args):
|
|
196
|
+
opts["cwd"] = args[i + 1]; i += 2; continue
|
|
197
|
+
rest.append(a); i += 1
|
|
198
|
+
return opts, rest
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _run_shell(profile: dict, command: str, cwd: str, timeout: int) -> int:
|
|
202
|
+
data = _call_remote(
|
|
203
|
+
profile, "execute_bash",
|
|
204
|
+
{"command": command, "cwd": cwd, "timeout": timeout},
|
|
205
|
+
http_timeout=(timeout or 60) + 15,
|
|
206
|
+
)
|
|
207
|
+
out = data.get("stdout", "")
|
|
208
|
+
if out:
|
|
209
|
+
sys.stdout.write(out if out.endswith("\n") else out + "\n")
|
|
210
|
+
if data.get("error"):
|
|
211
|
+
sys.stderr.write(f"hyd: {data['error']}\n")
|
|
212
|
+
_write_cwd(data.get("cwd", cwd)) # keep cwd in sync (via the shell wrapper)
|
|
213
|
+
return int(data.get("exit_code", 0) or 0)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _run_js(profile: dict, code: str, timeout: int) -> int:
|
|
217
|
+
data = _call_remote(profile, "execute_script", {"code": code}, http_timeout=(timeout or 60) + 15)
|
|
218
|
+
if isinstance(data, dict):
|
|
219
|
+
if data.get("error"):
|
|
220
|
+
sys.stderr.write(f"hyd: {data['error']}\n")
|
|
221
|
+
return 1
|
|
222
|
+
# execute_script returns {result, type, ...}; print just the result.
|
|
223
|
+
_print_value(data["result"] if "result" in data else data)
|
|
224
|
+
else:
|
|
225
|
+
_print_value(data)
|
|
226
|
+
return 0
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def cmd_run(args: list, bare: bool = False) -> int:
|
|
230
|
+
"""Type-adaptive run: shell on a terminal profile, JavaScript on a browser one."""
|
|
231
|
+
if bare:
|
|
232
|
+
opts, parts = {"profile": "", "timeout": None, "cwd": ""}, args
|
|
233
|
+
else:
|
|
234
|
+
opts, parts = _extract_opts(args)
|
|
235
|
+
payload = " ".join(parts).strip()
|
|
236
|
+
if not payload:
|
|
237
|
+
raise CliError("nothing to run. Usage: hyd '<shell command or JS>'")
|
|
238
|
+
name, profile = _resolve_profile(opts["profile"])
|
|
239
|
+
timeout = opts["timeout"] if opts["timeout"] is not None else 30
|
|
240
|
+
if _profile_type(profile) == "browser":
|
|
241
|
+
return _run_js(profile, payload, timeout)
|
|
242
|
+
return _run_shell(profile, payload, opts["cwd"] or _env_cwd(), timeout)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def cmd_sh(args: list) -> int:
|
|
246
|
+
opts, parts = _extract_opts(args)
|
|
247
|
+
command = " ".join(parts).strip()
|
|
248
|
+
if not command:
|
|
249
|
+
raise CliError("nothing to run. Usage: hyd sh '<command>'")
|
|
250
|
+
name, profile = _resolve_profile(opts["profile"])
|
|
251
|
+
if _profile_type(profile) == "browser":
|
|
252
|
+
raise CliError(f"profile '{name}' is a browser profile — use `hyd js '<code>'` (or bare `hyd '<code>'`).")
|
|
253
|
+
timeout = opts["timeout"] if opts["timeout"] is not None else 30
|
|
254
|
+
return _run_shell(profile, command, opts["cwd"] or _env_cwd(), timeout)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def cmd_js(args: list) -> int:
|
|
258
|
+
opts, parts = _extract_opts(args)
|
|
259
|
+
code = " ".join(parts).strip()
|
|
260
|
+
if not code:
|
|
261
|
+
raise CliError("nothing to run. Usage: hyd js '<javascript>'")
|
|
262
|
+
name, profile = _resolve_profile(opts["profile"])
|
|
263
|
+
if _profile_type(profile) != "browser":
|
|
264
|
+
raise CliError(f"profile '{name}' is a terminal profile — use `hyd sh '<command>'`.")
|
|
265
|
+
timeout = opts["timeout"] if opts["timeout"] is not None else 30
|
|
266
|
+
return _run_js(profile, code, timeout)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def cmd_call(args: list) -> int:
|
|
270
|
+
"""Generic passthrough: call any service function. `hyd call <fn> [--json '{...}'] [k=v ...]`."""
|
|
271
|
+
opts, rest = _extract_opts(args)
|
|
272
|
+
if not rest:
|
|
273
|
+
raise CliError("usage: hyd call <function> [--json '{...}'] [key=value ...]")
|
|
274
|
+
fn, params = rest[0], {}
|
|
275
|
+
i = 1
|
|
276
|
+
while i < len(rest):
|
|
277
|
+
a = rest[i]
|
|
278
|
+
if a == "--json" and i + 1 < len(rest):
|
|
279
|
+
params.update(json.loads(rest[i + 1])); i += 2; continue
|
|
280
|
+
if "=" in a:
|
|
281
|
+
k, v = a.split("=", 1)
|
|
282
|
+
params[k] = v; i += 1; continue
|
|
283
|
+
i += 1
|
|
284
|
+
_name, profile = _resolve_profile(opts["profile"])
|
|
285
|
+
_print_value(_call_remote(profile, fn, params))
|
|
286
|
+
return 0
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def cmd_nav(args: list) -> int:
|
|
290
|
+
if not args:
|
|
291
|
+
raise CliError("usage: hyd nav <url>")
|
|
292
|
+
name, profile = _resolve_profile()
|
|
293
|
+
if _profile_type(profile) != "browser":
|
|
294
|
+
raise CliError(f"profile '{name}' is not a browser profile.")
|
|
295
|
+
_print_value(_call_remote(profile, "navigate", {"url": args[0]}))
|
|
296
|
+
return 0
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def cmd_shot(args: list) -> int:
|
|
300
|
+
import base64
|
|
301
|
+
name, profile = _resolve_profile()
|
|
302
|
+
if _profile_type(profile) != "browser":
|
|
303
|
+
raise CliError(f"profile '{name}' is not a browser profile.")
|
|
304
|
+
data = _call_remote(profile, "take_screenshot", {})
|
|
305
|
+
b64 = data.get("base64") or (data.get("data_url", "").split(",", 1)[-1] if data.get("data_url") else "")
|
|
306
|
+
if not b64:
|
|
307
|
+
raise CliError(f"no screenshot data: {data.get('error', data)}")
|
|
308
|
+
path = args[0] if args else "screenshot.png"
|
|
309
|
+
with open(path, "wb") as f:
|
|
310
|
+
f.write(base64.b64decode(b64))
|
|
311
|
+
print(path)
|
|
312
|
+
return 0
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def cmd_py(args: list) -> int:
|
|
316
|
+
opts, code_parts = _extract_opts(args)
|
|
317
|
+
code = " ".join(code_parts).strip()
|
|
318
|
+
if not code:
|
|
319
|
+
raise CliError("nothing to run. Usage: hyd py '<python code>'")
|
|
320
|
+
name, profile = _resolve_profile(opts["profile"])
|
|
321
|
+
if _profile_type(profile) == "browser":
|
|
322
|
+
raise CliError(f"profile '{name}' is a browser profile — use `hyd js '<code>'`.")
|
|
323
|
+
data = _call_remote(profile, "execute_code", {"code": code})
|
|
324
|
+
if isinstance(data, dict):
|
|
325
|
+
if data.get("stdout"):
|
|
326
|
+
sys.stdout.write(data["stdout"])
|
|
327
|
+
for key in ("result", "value"):
|
|
328
|
+
if data.get(key) is not None:
|
|
329
|
+
print(data[key]); break
|
|
330
|
+
if data.get("error"):
|
|
331
|
+
sys.stderr.write(f"hyd: {data['error']}\n"); return 1
|
|
332
|
+
else:
|
|
333
|
+
print(data)
|
|
334
|
+
return 0
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# ---- profiles ------------------------------------------------------------
|
|
338
|
+
def cmd_profile(args: list) -> int:
|
|
339
|
+
if not args:
|
|
340
|
+
return cmd_profile_list([])
|
|
341
|
+
sub, rest = args[0], args[1:]
|
|
342
|
+
if sub == "add":
|
|
343
|
+
return cmd_profile_add(rest)
|
|
344
|
+
if sub in ("list", "ls"):
|
|
345
|
+
return cmd_profile_list(rest)
|
|
346
|
+
if sub == "show":
|
|
347
|
+
return cmd_profile_show(rest)
|
|
348
|
+
if sub in ("rm", "remove", "delete"):
|
|
349
|
+
return cmd_profile_rm(rest)
|
|
350
|
+
raise CliError(f"unknown 'profile' subcommand: {sub}")
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def cmd_profile_add(args: list) -> int:
|
|
354
|
+
use = "--use" in args
|
|
355
|
+
args = [a for a in args if a != "--use"]
|
|
356
|
+
opts, pos = {}, []
|
|
357
|
+
i = 0
|
|
358
|
+
while i < len(args):
|
|
359
|
+
a = args[i]
|
|
360
|
+
if a in ("--token", "--workspace", "--type") and i + 1 < len(args):
|
|
361
|
+
opts[a.lstrip("-")] = args[i + 1]; i += 2; continue
|
|
362
|
+
pos.append(a); i += 1
|
|
363
|
+
if len(pos) < 2:
|
|
364
|
+
raise CliError("usage: hyd profile add <name> <service_url> [--type terminal|browser] [--token T] [--use]")
|
|
365
|
+
name, url = pos[0], _normalize_url(pos[1])
|
|
366
|
+
ptype = opts.get("type")
|
|
367
|
+
if ptype and ptype not in ("terminal", "browser"):
|
|
368
|
+
raise CliError("--type must be 'terminal' or 'browser'")
|
|
369
|
+
profiles = _load_profiles()
|
|
370
|
+
entry = profiles.get(name, {})
|
|
371
|
+
entry["service_url"] = url
|
|
372
|
+
entry["type"] = ptype or entry.get("type") or _infer_type(url)
|
|
373
|
+
if opts.get("token"):
|
|
374
|
+
entry["token"] = opts["token"]
|
|
375
|
+
if opts.get("workspace"):
|
|
376
|
+
entry["workspace"] = opts["workspace"]
|
|
377
|
+
profiles[name] = entry
|
|
378
|
+
_save_profiles(profiles)
|
|
379
|
+
label = f"{url} [{entry['type']}]"
|
|
380
|
+
if use:
|
|
381
|
+
_emit_state({"HYD_PROFILE": name}, human=f"profile '{name}' -> {label} (now active)")
|
|
382
|
+
else:
|
|
383
|
+
print(f"# profile '{name}' -> {label} (activate: export HYD_PROFILE={name})")
|
|
384
|
+
return 0
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def cmd_profile_list(_args: list) -> int:
|
|
388
|
+
profiles = _load_profiles()
|
|
389
|
+
if not profiles:
|
|
390
|
+
print("no profiles. Add one: hyd profile add <name> <service_url> --use")
|
|
391
|
+
return 0
|
|
392
|
+
active = _env_profile()
|
|
393
|
+
for name, p in profiles.items():
|
|
394
|
+
mark = "*" if name == active else " "
|
|
395
|
+
tok = " (token)" if p.get("token") else ""
|
|
396
|
+
print(f"{mark} {name} [{_profile_type(p)}] {p.get('service_url', '')}{tok}")
|
|
397
|
+
return 0
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def cmd_profile_show(args: list) -> int:
|
|
401
|
+
if not args:
|
|
402
|
+
raise CliError("usage: hyd profile show <name>")
|
|
403
|
+
p = _load_profiles().get(args[0])
|
|
404
|
+
if not p:
|
|
405
|
+
raise CliError(f"unknown profile '{args[0]}'")
|
|
406
|
+
shown = dict(p)
|
|
407
|
+
if shown.get("token"):
|
|
408
|
+
shown["token"] = shown["token"][:6] + "…"
|
|
409
|
+
print(json.dumps({args[0]: shown}, indent=2))
|
|
410
|
+
return 0
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def cmd_profile_rm(args: list) -> int:
|
|
414
|
+
if not args:
|
|
415
|
+
raise CliError("usage: hyd profile rm <name>")
|
|
416
|
+
profiles = _load_profiles()
|
|
417
|
+
if args[0] not in profiles:
|
|
418
|
+
raise CliError(f"unknown profile '{args[0]}'")
|
|
419
|
+
del profiles[args[0]]
|
|
420
|
+
_save_profiles(profiles)
|
|
421
|
+
print(f"# removed profile '{args[0]}'")
|
|
422
|
+
return 0
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
# ---- current state commands (emit env exports) ---------------------------
|
|
426
|
+
def cmd_use(args: list) -> int:
|
|
427
|
+
if not args:
|
|
428
|
+
raise CliError("usage: hyd use <profile>")
|
|
429
|
+
name = args[0]
|
|
430
|
+
if name not in _load_profiles():
|
|
431
|
+
raise CliError(f"unknown profile '{name}'. Add it: hyd profile add {name} <service_url>")
|
|
432
|
+
_emit_state({"HYD_PROFILE": name}, human=f"active profile -> {name}")
|
|
433
|
+
return 0
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def cmd_cd(args: list) -> int:
|
|
437
|
+
target = args[0] if args else ""
|
|
438
|
+
_emit_state({"HYD_CWD": target}, human=f"cwd -> {target or '(remote default)'}")
|
|
439
|
+
return 0
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def cmd_pwd(_args: list) -> int:
|
|
443
|
+
print(_env_cwd() or "(remote default)")
|
|
444
|
+
return 0
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def cmd_status(_args: list) -> int:
|
|
448
|
+
try:
|
|
449
|
+
name, profile = _resolve_profile()
|
|
450
|
+
except CliError as e:
|
|
451
|
+
print(e); return 1
|
|
452
|
+
ptype = _profile_type(profile)
|
|
453
|
+
print(f"profile: {name} [{ptype}] cwd: {_env_cwd() or '(remote default)'} (HYD_PROFILE/HYD_CWD)")
|
|
454
|
+
print(f"url: {_normalize_url(profile['service_url'])}")
|
|
455
|
+
try:
|
|
456
|
+
if ptype == "browser":
|
|
457
|
+
info = _call_remote(profile, "get_page_info", {})
|
|
458
|
+
print(f"connected: url={info.get('url')} title={info.get('title')}")
|
|
459
|
+
else:
|
|
460
|
+
info = _call_remote(profile, "get_process_info", {})
|
|
461
|
+
print(f"connected: pid={info.get('pid')} host={info.get('hostname')} py={info.get('python_version')}")
|
|
462
|
+
except CliError as e:
|
|
463
|
+
print(f"UNREACHABLE: {e}"); return 1
|
|
464
|
+
return 0
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def cmd_shell_init(_args: list) -> int:
|
|
468
|
+
print(
|
|
469
|
+
"# hyd shell integration — add to your shell rc: eval \"$(hyd shell-init)\"\n"
|
|
470
|
+
"# Wraps hyd so `use`/`cd`/`profile add --use` update this shell's env and\n"
|
|
471
|
+
"# `hyd sh` keeps HYD_CWD in sync. State lives in env vars (per-terminal), not on disk.\n"
|
|
472
|
+
"hyd() {\n"
|
|
473
|
+
" local __f; __f=\"$(mktemp)\"\n"
|
|
474
|
+
" HYD_STATE_FILE=\"$__f\" command hyd \"$@\"; local __rc=$?\n"
|
|
475
|
+
" [ -s \"$__f\" ] && . \"$__f\"\n"
|
|
476
|
+
" rm -f \"$__f\"\n"
|
|
477
|
+
" return $__rc\n"
|
|
478
|
+
"}"
|
|
479
|
+
)
|
|
480
|
+
return 0
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _print_help() -> int:
|
|
484
|
+
print(
|
|
485
|
+
"""hyd — Hypha remote CLI: drive a terminal (Python process) OR a browser with minimal overhead
|
|
486
|
+
|
|
487
|
+
USAGE (adapts to the active profile's type)
|
|
488
|
+
hyd '<x>' Bare: runs <x> as SHELL on a terminal profile, JAVASCRIPT on a browser one
|
|
489
|
+
hyd sh '<command>' Force a shell command (terminal profile; cwd via HYD_CWD)
|
|
490
|
+
hyd js '<javascript>' Force JavaScript (browser profile) — returns the result
|
|
491
|
+
hyd py '<python>' Run Python via execute_code (terminal profile)
|
|
492
|
+
hyd call <fn> [--json '{…}'] [k=v …] Call ANY service function (either type)
|
|
493
|
+
hyd nav <url> | shot [file] Browser: navigate / save a screenshot (PNG)
|
|
494
|
+
hyd -p <profile> … Run against a specific profile (overrides HYD_PROFILE)
|
|
495
|
+
|
|
496
|
+
PROFILES (machines — stored on disk; each has a type: terminal|browser)
|
|
497
|
+
hyd profile add <name> <service_url> [--type terminal|browser] [--token T] [--use]
|
|
498
|
+
hyd profile list | show <name> | rm <name> (type inferred from the URL if omitted)
|
|
499
|
+
|
|
500
|
+
CURRENT STATE (env vars, per-terminal — NOT on disk)
|
|
501
|
+
export HYD_PROFILE=<name> Select the active profile for this shell
|
|
502
|
+
export HYD_CWD=<dir> Set the current remote directory for this shell
|
|
503
|
+
hyd use <name> Prints `export HYD_PROFILE=<name>` (eval it, or use the wrapper)
|
|
504
|
+
hyd cd <dir> | pwd Prints `export HYD_CWD=<dir>` / shows HYD_CWD
|
|
505
|
+
hyd shell-init Wrapper so use/cd/sh update this shell's env automatically
|
|
506
|
+
hyd status Show active profile + cwd and ping the remote
|
|
507
|
+
|
|
508
|
+
Profiles: $XDG_CONFIG_HOME/hypha-debugger (or ~/.hypha-debugger). Setup docs:
|
|
509
|
+
GET <service_url>/get_skill_md."""
|
|
510
|
+
)
|
|
511
|
+
return 0
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def main(argv=None) -> int:
|
|
515
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
516
|
+
if not argv:
|
|
517
|
+
return _print_help()
|
|
518
|
+
cmd = argv[0]
|
|
519
|
+
try:
|
|
520
|
+
if cmd in ("help", "-h", "--help"):
|
|
521
|
+
return _print_help()
|
|
522
|
+
if cmd in ("version", "--version"):
|
|
523
|
+
from hypha_debugger import __version__
|
|
524
|
+
print(f"hyd (hypha-debugger) {__version__}")
|
|
525
|
+
return 0
|
|
526
|
+
if cmd in ("run", "exec"):
|
|
527
|
+
return cmd_run(argv[1:])
|
|
528
|
+
if cmd == "sh":
|
|
529
|
+
return cmd_sh(argv[1:])
|
|
530
|
+
if cmd == "js":
|
|
531
|
+
return cmd_js(argv[1:])
|
|
532
|
+
if cmd == "py":
|
|
533
|
+
return cmd_py(argv[1:])
|
|
534
|
+
if cmd == "call":
|
|
535
|
+
return cmd_call(argv[1:])
|
|
536
|
+
if cmd == "nav":
|
|
537
|
+
return cmd_nav(argv[1:])
|
|
538
|
+
if cmd == "shot":
|
|
539
|
+
return cmd_shot(argv[1:])
|
|
540
|
+
if cmd in ("profile", "profiles"):
|
|
541
|
+
return cmd_profile(argv[1:] if cmd == "profile" else ["list"] + argv[1:])
|
|
542
|
+
if cmd == "use":
|
|
543
|
+
return cmd_use(argv[1:])
|
|
544
|
+
if cmd == "cd":
|
|
545
|
+
return cmd_cd(argv[1:])
|
|
546
|
+
if cmd == "pwd":
|
|
547
|
+
return cmd_pwd(argv[1:])
|
|
548
|
+
if cmd == "status":
|
|
549
|
+
return cmd_status(argv[1:])
|
|
550
|
+
if cmd == "shell-init":
|
|
551
|
+
return cmd_shell_init(argv[1:])
|
|
552
|
+
# Bare fallback: run the whole argv, dispatching by profile type
|
|
553
|
+
# (shell on a terminal profile, JavaScript on a browser one).
|
|
554
|
+
return cmd_run(argv, bare=True)
|
|
555
|
+
except CliError as e:
|
|
556
|
+
sys.stderr.write(f"hyd: {e}\n")
|
|
557
|
+
return 2
|
|
558
|
+
except KeyboardInterrupt:
|
|
559
|
+
return 130
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
if __name__ == "__main__":
|
|
563
|
+
sys.exit(main())
|
|
@@ -9,6 +9,7 @@ from typing import Optional
|
|
|
9
9
|
|
|
10
10
|
from hypha_debugger.services.info import get_process_info, get_installed_packages
|
|
11
11
|
from hypha_debugger.services.execute import execute_code
|
|
12
|
+
from hypha_debugger.services.shell import execute_bash
|
|
12
13
|
from hypha_debugger.services.inspect_vars import (
|
|
13
14
|
get_variable,
|
|
14
15
|
list_variables,
|
|
@@ -47,32 +48,19 @@ def _build_service_url(server_url: str, service_id: str) -> str:
|
|
|
47
48
|
|
|
48
49
|
|
|
49
50
|
def _build_instruction_block(service_url: str, token: str = "") -> str:
|
|
50
|
-
"""
|
|
51
|
+
"""One-sentence instruction to paste into an AI agent.
|
|
51
52
|
|
|
52
|
-
|
|
53
|
+
Says what it is and where the API is; the agent reads the URL
|
|
54
|
+
(get_skill_md) for everything else.
|
|
53
55
|
"""
|
|
54
|
-
auth =
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
""
|
|
61
|
-
|
|
62
|
-
]
|
|
63
|
-
if token:
|
|
64
|
-
lines.append(f'TOKEN="{token}"')
|
|
65
|
-
lines += [
|
|
66
|
-
"",
|
|
67
|
-
"# Execute Python code:",
|
|
68
|
-
f'curl -X POST "$SERVICE_URL/execute_code"{auth}'
|
|
69
|
-
' -H "Content-Type: application/json"'
|
|
70
|
-
" -d '{\"code\": \"import sys; sys.version\"}'",
|
|
71
|
-
"",
|
|
72
|
-
"# Get full API docs (all functions, parameters, examples):",
|
|
73
|
-
f'curl "$SERVICE_URL/get_skill_md"{auth}',
|
|
74
|
-
]
|
|
75
|
-
return "\n".join(lines)
|
|
56
|
+
auth = (
|
|
57
|
+
f" (auth header: Authorization: Bearer {token})" if token else ""
|
|
58
|
+
)
|
|
59
|
+
return (
|
|
60
|
+
"A Hypha debugger is attached to this live Python process — call its HTTP "
|
|
61
|
+
f"API at {service_url} to run code and inspect the process; GET "
|
|
62
|
+
f"{service_url}/get_skill_md for the full tool list and usage{auth}."
|
|
63
|
+
)
|
|
76
64
|
|
|
77
65
|
|
|
78
66
|
def _print_session_info(
|
|
@@ -241,6 +229,7 @@ async def start_debugger(
|
|
|
241
229
|
"get_process_info": get_process_info,
|
|
242
230
|
"get_installed_packages": get_installed_packages,
|
|
243
231
|
"execute_code": execute_code,
|
|
232
|
+
"execute_bash": execute_bash,
|
|
244
233
|
"get_variable": get_variable,
|
|
245
234
|
"list_variables": list_variables,
|
|
246
235
|
"get_stack_trace": get_stack_trace,
|
|
@@ -324,6 +313,7 @@ def start_debugger_sync(
|
|
|
324
313
|
"get_process_info": get_process_info,
|
|
325
314
|
"get_installed_packages": get_installed_packages,
|
|
326
315
|
"execute_code": execute_code,
|
|
316
|
+
"execute_bash": execute_bash,
|
|
327
317
|
"get_variable": get_variable,
|
|
328
318
|
"list_variables": list_variables,
|
|
329
319
|
"get_stack_trace": get_stack_trace,
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Stateless remote shell execution service.
|
|
2
|
+
|
|
3
|
+
Unlike ``execute_code`` (a persistent Python REPL), ``execute_bash`` runs each
|
|
4
|
+
command in a **fresh** ``bash -c`` — the remote holds NO per-connection state.
|
|
5
|
+
The *client* (e.g. the ``hyd`` CLI) tracks the "current directory" and passes it
|
|
6
|
+
in as ``cwd``; the call returns the directory AFTER the command (so a ``cd`` in
|
|
7
|
+
the command is reflected back), letting the client emulate a persistent terminal
|
|
8
|
+
while keeping the server stateless (scales, survives reconnects).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import shlex
|
|
13
|
+
import subprocess
|
|
14
|
+
from typing import Any, Dict, Optional
|
|
15
|
+
|
|
16
|
+
# Default timeout for a shell command (seconds).
|
|
17
|
+
DEFAULT_TIMEOUT = 30
|
|
18
|
+
# Cap the returned output so a runaway command can't return megabytes.
|
|
19
|
+
MAX_OUTPUT_CHARS = 200_000
|
|
20
|
+
|
|
21
|
+
# Unique markers we append to the command to read back the final cwd + exit code
|
|
22
|
+
# without them colliding with normal output.
|
|
23
|
+
_MARK_CWD = "__HYD_CWD_9f3a2b__:"
|
|
24
|
+
_MARK_EC = "__HYD_EC_9f3a2b__:"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _run_bash(command: str, cwd: str, env: Optional[Dict[str, str]], timeout: int) -> Dict[str, Any]:
|
|
28
|
+
start_dir = cwd or os.getcwd()
|
|
29
|
+
exports = ""
|
|
30
|
+
if env:
|
|
31
|
+
exports = "".join(
|
|
32
|
+
f"export {k}={shlex.quote(str(v))}\n" for k, v in env.items()
|
|
33
|
+
)
|
|
34
|
+
# Wrapper: cd into the requested dir, run the command, then emit the final
|
|
35
|
+
# pwd + exit code on marker lines we strip out of the returned output.
|
|
36
|
+
wrapper = (
|
|
37
|
+
f"cd {shlex.quote(start_dir)} 2>/dev/null || cd \"$HOME\" 2>/dev/null || true\n"
|
|
38
|
+
f"{exports}"
|
|
39
|
+
f"{command}\n"
|
|
40
|
+
f"__hyd_ec=$?\n"
|
|
41
|
+
f'printf "\\n{_MARK_CWD}%s\\n{_MARK_EC}%s\\n" "$(pwd)" "$__hyd_ec"\n'
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
proc = subprocess.run(
|
|
46
|
+
["bash", "-c", wrapper],
|
|
47
|
+
stdout=subprocess.PIPE,
|
|
48
|
+
stderr=subprocess.STDOUT, # merge stderr for a terminal-like feel
|
|
49
|
+
timeout=timeout if timeout and timeout > 0 else None,
|
|
50
|
+
text=True,
|
|
51
|
+
)
|
|
52
|
+
raw = proc.stdout or ""
|
|
53
|
+
except subprocess.TimeoutExpired as e:
|
|
54
|
+
partial = e.stdout or ""
|
|
55
|
+
if isinstance(partial, bytes):
|
|
56
|
+
partial = partial.decode("utf-8", "replace")
|
|
57
|
+
return {
|
|
58
|
+
"stdout": _truncate(_strip_markers(partial)[0]),
|
|
59
|
+
"exit_code": 124,
|
|
60
|
+
"cwd": start_dir,
|
|
61
|
+
"error": f"Command timed out after {timeout}s",
|
|
62
|
+
}
|
|
63
|
+
except FileNotFoundError:
|
|
64
|
+
return {
|
|
65
|
+
"stdout": "",
|
|
66
|
+
"exit_code": 127,
|
|
67
|
+
"cwd": start_dir,
|
|
68
|
+
"error": "bash not found on the remote host",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
output, final_cwd, exit_code = _parse(raw, start_dir)
|
|
72
|
+
# If the command called `exit`/`kill` etc., our epilogue never ran and the
|
|
73
|
+
# markers are absent — fall back to bash's real return code.
|
|
74
|
+
if _MARK_EC not in raw:
|
|
75
|
+
exit_code = proc.returncode
|
|
76
|
+
return {"stdout": _truncate(output), "exit_code": exit_code, "cwd": final_cwd}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _parse(raw: str, fallback_cwd: str):
|
|
80
|
+
"""Split the trailing cwd/exit-code markers off the real output."""
|
|
81
|
+
cwd = fallback_cwd
|
|
82
|
+
ec = 0
|
|
83
|
+
idx = raw.rfind("\n" + _MARK_CWD)
|
|
84
|
+
if idx == -1:
|
|
85
|
+
idx = raw.rfind(_MARK_CWD)
|
|
86
|
+
if idx != -1:
|
|
87
|
+
tail = raw[idx:]
|
|
88
|
+
output = raw[:idx]
|
|
89
|
+
for line in tail.splitlines():
|
|
90
|
+
if _MARK_CWD in line:
|
|
91
|
+
cwd = line.split(_MARK_CWD, 1)[1].strip() or fallback_cwd
|
|
92
|
+
elif _MARK_EC in line:
|
|
93
|
+
try:
|
|
94
|
+
ec = int(line.split(_MARK_EC, 1)[1].strip())
|
|
95
|
+
except ValueError:
|
|
96
|
+
ec = 0
|
|
97
|
+
else:
|
|
98
|
+
output = raw
|
|
99
|
+
return output, cwd, ec
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _strip_markers(raw: str):
|
|
103
|
+
output, cwd, ec = _parse(raw, "")
|
|
104
|
+
return output, cwd, ec
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _truncate(text: str) -> str:
|
|
108
|
+
if len(text) > MAX_OUTPUT_CHARS:
|
|
109
|
+
return text[:MAX_OUTPUT_CHARS] + f"\n... [truncated at {MAX_OUTPUT_CHARS} chars]"
|
|
110
|
+
return text
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
from pydantic import Field
|
|
115
|
+
from hypha_rpc.utils.schema import schema_function
|
|
116
|
+
|
|
117
|
+
@schema_function
|
|
118
|
+
def execute_bash(
|
|
119
|
+
command: str = Field(..., description="Shell command to run (bash)."),
|
|
120
|
+
cwd: str = Field(
|
|
121
|
+
default="",
|
|
122
|
+
description=(
|
|
123
|
+
"Working directory to run in. The remote is STATELESS: pass the "
|
|
124
|
+
"current directory here and use the returned `cwd` for the next "
|
|
125
|
+
"call to emulate a persistent shell. Empty = the process cwd."
|
|
126
|
+
),
|
|
127
|
+
),
|
|
128
|
+
env: Optional[Dict[str, str]] = Field(
|
|
129
|
+
default=None,
|
|
130
|
+
description="Optional environment variables to export before the command.",
|
|
131
|
+
),
|
|
132
|
+
timeout: int = Field(
|
|
133
|
+
default=DEFAULT_TIMEOUT,
|
|
134
|
+
description=f"Max seconds before the command is killed (default {DEFAULT_TIMEOUT}, 0 = no limit).",
|
|
135
|
+
),
|
|
136
|
+
) -> Dict[str, Any]:
|
|
137
|
+
"""Run a shell command on the remote host and return its output.
|
|
138
|
+
|
|
139
|
+
Stateless: each call is a fresh shell. Returns {stdout (stderr merged in),
|
|
140
|
+
exit_code, cwd} where `cwd` is the directory AFTER the command — pass it
|
|
141
|
+
back as `cwd` next time so `cd` persists (this is what the `hyd` CLI does).
|
|
142
|
+
"""
|
|
143
|
+
return _run_bash(command, cwd, env, timeout)
|
|
144
|
+
|
|
145
|
+
except ImportError:
|
|
146
|
+
|
|
147
|
+
def execute_bash(
|
|
148
|
+
command: str,
|
|
149
|
+
cwd: str = "",
|
|
150
|
+
env: Optional[Dict[str, str]] = None,
|
|
151
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
152
|
+
) -> Dict[str, Any]:
|
|
153
|
+
"""Run a shell command on the remote host and return its output."""
|
|
154
|
+
return _run_bash(command, cwd, env, timeout)
|
|
@@ -48,6 +48,8 @@ except ImportError:
|
|
|
48
48
|
_MODULES = {
|
|
49
49
|
"debugger": "hypha_debugger.debugger",
|
|
50
50
|
"services.execute": "hypha_debugger.services.execute",
|
|
51
|
+
"services.shell": "hypha_debugger.services.shell",
|
|
52
|
+
"cli": "hypha_debugger.cli",
|
|
51
53
|
"services.filesystem": "hypha_debugger.services.filesystem",
|
|
52
54
|
"services.info": "hypha_debugger.services.info",
|
|
53
55
|
"services.inspect_vars": "hypha_debugger.services.inspect_vars",
|
|
@@ -94,6 +96,45 @@ to execute code, read/write files, inspect variables, and install packages.
|
|
|
94
96
|
persistent Python REPL where variables, imports, and functions survive across
|
|
95
97
|
calls. The other endpoints are convenience shortcuts.
|
|
96
98
|
|
|
99
|
+
## Fast path — the `hyd` CLI (recommended for agents)
|
|
100
|
+
|
|
101
|
+
For repeated shell work, don't hand-write authenticated curl calls — install the
|
|
102
|
+
`hyd` client CLI once and run remote commands with almost no per-call overhead.
|
|
103
|
+
It stores this connection as a named **profile** (URL + token) on disk, so later
|
|
104
|
+
calls carry no auth or JSON boilerplate. Bootstrap yourself:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
pip install hypha-debugger # ships the `hyd` command
|
|
108
|
+
hyd profile add main "<THIS_SERVICE_URL>" # add --token <t> if the URL is token-protected
|
|
109
|
+
export HYD_PROFILE=main # select it (env var — per-terminal, not on disk)
|
|
110
|
+
|
|
111
|
+
hyd sh 'uname -a && pwd' # run a remote shell command
|
|
112
|
+
hyd 'ls -la' # bare form: anything not a subcommand runs as a command
|
|
113
|
+
hyd py 'import sys; sys.version' # run Python via execute_code
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Key ideas:
|
|
117
|
+
- **The remote is stateless.** The "current directory" and "current profile" live
|
|
118
|
+
in the environment variables `HYD_CWD` and `HYD_PROFILE` (per shell), so different
|
|
119
|
+
terminals are isolated and nothing session-specific is written to disk. Set
|
|
120
|
+
`export HYD_CWD=/some/dir` to fix a working directory, or run
|
|
121
|
+
`eval "$(hyd shell-init)"` once so `cd`/`cwd` stay in sync automatically after each
|
|
122
|
+
`hyd sh`.
|
|
123
|
+
- **Multiple machines**: add more profiles (`hyd profile add other "<url>" --token …`)
|
|
124
|
+
and switch with `export HYD_PROFILE=other` or a one-off `hyd -p other sh '…'`.
|
|
125
|
+
- **Terminal *or* browser**: each profile has a `type` (inferred from the URL). A
|
|
126
|
+
terminal profile (this Python debugger) runs shell/Python; a `browser` profile (a
|
|
127
|
+
Hypha Navigator web service) runs JavaScript. The SAME bare `hyd '<x>'` adapts —
|
|
128
|
+
shell on a terminal profile, JS on a browser one — or force it with `hyd sh` /
|
|
129
|
+
`hyd js`. `hyd call <fn> [--json '{…}']` calls any function; `hyd nav <url>` /
|
|
130
|
+
`hyd shot [file]` are browser shortcuts. Example:
|
|
131
|
+
`hyd profile add web "<navigator-url>" --type browser --use && hyd 'document.title'`.
|
|
132
|
+
- `hyd sh` streams stdout/stderr and exits with the remote command's exit code, so
|
|
133
|
+
it behaves like a local shell. Run `hyd` (no args) for full help.
|
|
134
|
+
|
|
135
|
+
The raw HTTP API below still works (and powers the CLI) — use it directly if you
|
|
136
|
+
can't install the CLI.
|
|
137
|
+
|
|
97
138
|
## Quick Start
|
|
98
139
|
|
|
99
140
|
```bash
|
|
@@ -128,6 +169,19 @@ captured as the return value via AST parsing.
|
|
|
128
169
|
- Variables, functions, and imports persist across calls.
|
|
129
170
|
- Example: `"x = 1\\nx + 1"` returns `{ok: true, result: 2}`.
|
|
130
171
|
|
|
172
|
+
### execute_bash(command, cwd?, env?, timeout?) — remote shell (STATELESS)
|
|
173
|
+
Run a shell command on the remote host. Each call is a fresh shell (no server
|
|
174
|
+
state). To emulate a persistent terminal, pass the current directory as `cwd` and
|
|
175
|
+
reuse the returned `cwd` on the next call — this is exactly what the `hyd` CLI
|
|
176
|
+
above does for you.
|
|
177
|
+
- **command** (str, required): bash command line.
|
|
178
|
+
- **cwd** (str, default `""`): directory to run in (`""` = the process cwd).
|
|
179
|
+
- **env** (object, optional): env vars to export before the command.
|
|
180
|
+
- **timeout** (int, default `30`): seconds; 0 = no limit.
|
|
181
|
+
- **Returns**: `{stdout, exit_code, cwd}` — `stdout` has stderr merged in; `cwd` is
|
|
182
|
+
the directory AFTER the command (so `cd` is reflected).
|
|
183
|
+
- Example: `curl -X POST "$SERVICE_URL/execute_bash" -d '{"command":"ls -la","cwd":"/tmp"}'`
|
|
184
|
+
|
|
131
185
|
### get_process_info()
|
|
132
186
|
PID, CWD, Python version, hostname, platform, memory usage, CPU count.
|
|
133
187
|
- **Example**: `curl "$SERVICE_URL/get_process_info"`
|
|
@@ -2,6 +2,7 @@ README.md
|
|
|
2
2
|
pyproject.toml
|
|
3
3
|
hypha_debugger/__init__.py
|
|
4
4
|
hypha_debugger/__main__.py
|
|
5
|
+
hypha_debugger/cli.py
|
|
5
6
|
hypha_debugger/debugger.py
|
|
6
7
|
hypha_debugger.egg-info/PKG-INFO
|
|
7
8
|
hypha_debugger.egg-info/SOURCES.txt
|
|
@@ -14,7 +15,10 @@ hypha_debugger/services/execute.py
|
|
|
14
15
|
hypha_debugger/services/filesystem.py
|
|
15
16
|
hypha_debugger/services/info.py
|
|
16
17
|
hypha_debugger/services/inspect_vars.py
|
|
18
|
+
hypha_debugger/services/shell.py
|
|
17
19
|
hypha_debugger/services/source.py
|
|
18
20
|
hypha_debugger/utils/__init__.py
|
|
19
21
|
hypha_debugger/utils/env.py
|
|
20
|
-
tests/
|
|
22
|
+
tests/test_cli.py
|
|
23
|
+
tests/test_services.py
|
|
24
|
+
tests/test_shell.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "hypha-debugger"
|
|
7
|
-
version = "0.1
|
|
7
|
+
version = "0.2.1"
|
|
8
8
|
description = "Injectable debugger for Python processes and AI agents, powered by Hypha RPC"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -42,6 +42,7 @@ full = [
|
|
|
42
42
|
|
|
43
43
|
[project.scripts]
|
|
44
44
|
hypha-debugger = "hypha_debugger.__main__:main"
|
|
45
|
+
hyd = "hypha_debugger.cli:main"
|
|
45
46
|
|
|
46
47
|
[tool.setuptools.packages.find]
|
|
47
48
|
where = ["."]
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Tests for the `hyd` client CLI (profiles on disk, current state in env vars)."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from hypha_debugger import cli
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.fixture
|
|
9
|
+
def cfg(tmp_path, monkeypatch):
|
|
10
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
11
|
+
for var in ("HYD_PROFILE", "HYD_CWD", "HYD_STATE_FILE"):
|
|
12
|
+
monkeypatch.delenv(var, raising=False)
|
|
13
|
+
return tmp_path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_profile_add_normalizes_url(cfg):
|
|
17
|
+
assert cli.main(["profile", "add", "main", "https://h.io/services/x/get_skill_md"]) == 0
|
|
18
|
+
profs = cli._load_profiles()
|
|
19
|
+
assert profs["main"]["service_url"] == "https://h.io/services/x"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_profile_add_with_token_and_list(cfg, capsys):
|
|
23
|
+
cli.main(["profile", "add", "main", "https://h.io/s/x", "--token", "secret"])
|
|
24
|
+
assert cli._load_profiles()["main"]["token"] == "secret"
|
|
25
|
+
cli.main(["profile", "list"])
|
|
26
|
+
out = capsys.readouterr().out
|
|
27
|
+
assert "main" in out and "(token)" in out
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_profile_rm(cfg):
|
|
31
|
+
cli.main(["profile", "add", "p", "https://h.io/s/x"])
|
|
32
|
+
cli.main(["profile", "rm", "p"])
|
|
33
|
+
assert "p" not in cli._load_profiles()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_use_emits_export_no_disk_state(cfg, capsys):
|
|
37
|
+
cli.main(["profile", "add", "main", "https://h.io/s/x"])
|
|
38
|
+
cli.main(["use", "main"])
|
|
39
|
+
out = capsys.readouterr().out
|
|
40
|
+
assert "export HYD_PROFILE=main" in out
|
|
41
|
+
# No session/current-state file should exist on disk.
|
|
42
|
+
assert not (cfg / "hypha-debugger" / "sessions").exists()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_cd_emits_export(cfg, capsys):
|
|
46
|
+
cli.main(["cd", "/var/log"])
|
|
47
|
+
assert "export HYD_CWD=/var/log" in capsys.readouterr().out
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_resolve_profile_from_env(cfg, monkeypatch):
|
|
51
|
+
cli.main(["profile", "add", "a", "https://a.io/s/1"])
|
|
52
|
+
cli.main(["profile", "add", "b", "https://b.io/s/2"])
|
|
53
|
+
monkeypatch.setenv("HYD_PROFILE", "b")
|
|
54
|
+
name, prof = cli._resolve_profile()
|
|
55
|
+
assert name == "b" and prof["service_url"].endswith("/s/2")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_resolve_profile_flag_overrides_env(cfg, monkeypatch):
|
|
59
|
+
cli.main(["profile", "add", "a", "https://a.io/s/1"])
|
|
60
|
+
cli.main(["profile", "add", "b", "https://b.io/s/2"])
|
|
61
|
+
monkeypatch.setenv("HYD_PROFILE", "a")
|
|
62
|
+
name, _ = cli._resolve_profile("b")
|
|
63
|
+
assert name == "b"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_resolve_sole_profile_when_unset(cfg):
|
|
67
|
+
cli.main(["profile", "add", "only", "https://o.io/s/1"])
|
|
68
|
+
name, _ = cli._resolve_profile()
|
|
69
|
+
assert name == "only"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_sh_calls_execute_bash_and_returns_exit_code(cfg, monkeypatch):
|
|
73
|
+
cli.main(["profile", "add", "main", "https://h.io/s/x"])
|
|
74
|
+
monkeypatch.setenv("HYD_PROFILE", "main")
|
|
75
|
+
seen = {}
|
|
76
|
+
|
|
77
|
+
def fake(profile, fn, params, http_timeout=75):
|
|
78
|
+
seen["fn"] = fn
|
|
79
|
+
seen["params"] = params
|
|
80
|
+
return {"stdout": "hi\n", "exit_code": 7, "cwd": "/tmp"}
|
|
81
|
+
|
|
82
|
+
monkeypatch.setattr(cli, "_call_remote", fake)
|
|
83
|
+
rc = cli.main(["sh", "echo hi"])
|
|
84
|
+
assert rc == 7
|
|
85
|
+
assert seen["fn"] == "execute_bash"
|
|
86
|
+
assert seen["params"]["command"] == "echo hi"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_sh_persists_cwd_via_state_file(cfg, monkeypatch, tmp_path):
|
|
90
|
+
cli.main(["profile", "add", "main", "https://h.io/s/x"])
|
|
91
|
+
monkeypatch.setenv("HYD_PROFILE", "main")
|
|
92
|
+
sf = tmp_path / "state.sh"
|
|
93
|
+
sf.write_text("")
|
|
94
|
+
monkeypatch.setenv("HYD_STATE_FILE", str(sf))
|
|
95
|
+
monkeypatch.setattr(cli, "_call_remote", lambda *a, **k: {"stdout": "", "exit_code": 0, "cwd": "/var"})
|
|
96
|
+
cli.main(["sh", "pwd"])
|
|
97
|
+
assert "export HYD_CWD=/var" in sf.read_text()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_sh_uses_env_cwd(cfg, monkeypatch):
|
|
101
|
+
cli.main(["profile", "add", "main", "https://h.io/s/x"])
|
|
102
|
+
monkeypatch.setenv("HYD_PROFILE", "main")
|
|
103
|
+
monkeypatch.setenv("HYD_CWD", "/opt")
|
|
104
|
+
seen = {}
|
|
105
|
+
monkeypatch.setattr(cli, "_call_remote", lambda profile, fn, params, http_timeout=75: seen.update(params) or {"stdout": "", "exit_code": 0, "cwd": "/opt"})
|
|
106
|
+
cli.main(["sh", "pwd"])
|
|
107
|
+
assert seen["cwd"] == "/opt"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_bare_fallback_runs_as_command(cfg, monkeypatch):
|
|
111
|
+
cli.main(["profile", "add", "main", "https://h.io/s/x"])
|
|
112
|
+
monkeypatch.setenv("HYD_PROFILE", "main")
|
|
113
|
+
seen = {}
|
|
114
|
+
monkeypatch.setattr(cli, "_call_remote", lambda profile, fn, params, http_timeout=75: seen.update(params) or {"stdout": "", "exit_code": 0, "cwd": ""})
|
|
115
|
+
cli.main(["ls", "-la"])
|
|
116
|
+
assert seen["command"] == "ls -la"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_type_inference_browser_and_terminal(cfg):
|
|
120
|
+
cli.main(["profile", "add", "web", "https://h.io/services/web-navigator-abc"])
|
|
121
|
+
cli.main(["profile", "add", "box", "https://h.io/services/py-debugger-xyz"])
|
|
122
|
+
profs = cli._load_profiles()
|
|
123
|
+
assert profs["web"]["type"] == "browser"
|
|
124
|
+
assert profs["box"]["type"] == "terminal"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_type_explicit_override(cfg):
|
|
128
|
+
cli.main(["profile", "add", "x", "https://h.io/services/anything", "--type", "browser"])
|
|
129
|
+
assert cli._load_profiles()["x"]["type"] == "browser"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_bare_dispatches_by_type(cfg, monkeypatch):
|
|
133
|
+
cli.main(["profile", "add", "web", "https://h.io/services/web-navigator-abc"])
|
|
134
|
+
monkeypatch.setenv("HYD_PROFILE", "web")
|
|
135
|
+
seen = {}
|
|
136
|
+
monkeypatch.setattr(cli, "_call_remote", lambda profile, fn, params, http_timeout=75: seen.update(fn=fn, params=params) or {"result": "My Page", "type": "string"})
|
|
137
|
+
rc = cli.main(["document.title"]) # bare, browser profile -> execute_script
|
|
138
|
+
assert rc == 0 and seen["fn"] == "execute_script" and seen["params"]["code"] == "document.title"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_js_result_printed(cfg, monkeypatch, capsys):
|
|
142
|
+
cli.main(["profile", "add", "web", "https://h.io/services/web-navigator-abc"])
|
|
143
|
+
monkeypatch.setenv("HYD_PROFILE", "web")
|
|
144
|
+
monkeypatch.setattr(cli, "_call_remote", lambda *a, **k: {"result": {"a": 1}, "type": "object"})
|
|
145
|
+
cli.main(["js", "({a:1})"])
|
|
146
|
+
assert '"a": 1' in capsys.readouterr().out
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_sh_rejects_browser_profile(cfg, monkeypatch, capsys):
|
|
150
|
+
cli.main(["profile", "add", "web", "https://h.io/services/web-navigator-abc"])
|
|
151
|
+
monkeypatch.setenv("HYD_PROFILE", "web")
|
|
152
|
+
rc = cli.main(["sh", "ls"])
|
|
153
|
+
assert rc == 2 and "browser profile" in capsys.readouterr().err
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_js_rejects_terminal_profile(cfg, monkeypatch, capsys):
|
|
157
|
+
cli.main(["profile", "add", "box", "https://h.io/services/py-debugger-xyz"])
|
|
158
|
+
monkeypatch.setenv("HYD_PROFILE", "box")
|
|
159
|
+
rc = cli.main(["js", "1+1"])
|
|
160
|
+
assert rc == 2 and "terminal profile" in capsys.readouterr().err
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_call_generic_passthrough(cfg, monkeypatch):
|
|
164
|
+
cli.main(["profile", "add", "web", "https://h.io/services/web-navigator-abc"])
|
|
165
|
+
monkeypatch.setenv("HYD_PROFILE", "web")
|
|
166
|
+
seen = {}
|
|
167
|
+
monkeypatch.setattr(cli, "_call_remote", lambda profile, fn, params, http_timeout=75: seen.update(fn=fn, params=params) or {"ok": True})
|
|
168
|
+
cli.main(["call", "navigate", "--json", '{"url":"https://x.com"}'])
|
|
169
|
+
assert seen["fn"] == "navigate" and seen["params"]["url"] == "https://x.com"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_p_flag_selects_profile(cfg, monkeypatch):
|
|
173
|
+
cli.main(["profile", "add", "a", "https://a.io/s/1"])
|
|
174
|
+
cli.main(["profile", "add", "b", "https://b.io/s/2"])
|
|
175
|
+
monkeypatch.setenv("HYD_PROFILE", "a")
|
|
176
|
+
seen = {}
|
|
177
|
+
|
|
178
|
+
def fake(profile, fn, params, http_timeout=75):
|
|
179
|
+
seen["url"] = profile["service_url"]
|
|
180
|
+
return {"stdout": "", "exit_code": 0, "cwd": ""}
|
|
181
|
+
|
|
182
|
+
monkeypatch.setattr(cli, "_call_remote", fake)
|
|
183
|
+
cli.main(["sh", "-p", "b", "hostname"])
|
|
184
|
+
assert seen["url"].endswith("/s/2")
|
|
@@ -270,19 +270,17 @@ def test_write_file_invalid_mode():
|
|
|
270
270
|
|
|
271
271
|
def test_instruction_block_no_token():
|
|
272
272
|
from hypha_debugger.debugger import _build_instruction_block
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
assert
|
|
276
|
-
assert "Authorization" not in block
|
|
277
|
-
assert "execute_code" in block
|
|
273
|
+
url = "https://example.com/ws/services/py-debugger-abc"
|
|
274
|
+
block = _build_instruction_block(url)
|
|
275
|
+
assert url in block
|
|
278
276
|
assert "get_skill_md" in block
|
|
277
|
+
assert "Authorization" not in block # no token -> no auth header
|
|
279
278
|
|
|
280
279
|
|
|
281
280
|
def test_instruction_block_with_token():
|
|
282
281
|
from hypha_debugger.debugger import _build_instruction_block
|
|
283
282
|
block = _build_instruction_block("https://example.com/ws/services/py-debugger", "mytoken123")
|
|
284
|
-
assert
|
|
285
|
-
assert "Authorization" in block
|
|
283
|
+
assert "Authorization: Bearer mytoken123" in block
|
|
286
284
|
|
|
287
285
|
|
|
288
286
|
# --- source ---
|
|
@@ -341,6 +339,6 @@ def test_debug_session_print_instructions(capsys):
|
|
|
341
339
|
captured = capsys.readouterr()
|
|
342
340
|
assert "WARNING" in captured.out
|
|
343
341
|
assert "trusted" in captured.out
|
|
344
|
-
assert "
|
|
342
|
+
assert "get_skill_md" in captured.out
|
|
345
343
|
assert isinstance(result, str)
|
|
346
|
-
assert "
|
|
344
|
+
assert "py-debugger-abc" in result
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Tests for the stateless remote shell service (no server required)."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from hypha_debugger.services.shell import execute_bash
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_basic_stdout():
|
|
9
|
+
r = execute_bash(command="echo hello")
|
|
10
|
+
assert r["exit_code"] == 0
|
|
11
|
+
assert "hello" in r["stdout"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_exit_code_propagates():
|
|
15
|
+
r = execute_bash(command="exit 3")
|
|
16
|
+
assert r["exit_code"] == 3
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_stderr_merged_into_stdout():
|
|
20
|
+
r = execute_bash(command="echo oops >&2")
|
|
21
|
+
assert "oops" in r["stdout"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_cwd_reflected_after_cd():
|
|
25
|
+
r = execute_bash(command="cd /tmp && pwd", cwd="")
|
|
26
|
+
assert r["exit_code"] == 0
|
|
27
|
+
assert os.path.realpath(r["cwd"]) == os.path.realpath("/tmp")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_cwd_argument_used():
|
|
31
|
+
r = execute_bash(command="pwd", cwd="/tmp")
|
|
32
|
+
assert os.path.realpath(r["stdout"].strip()) == os.path.realpath("/tmp")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_stateless_no_shared_process():
|
|
36
|
+
# First call cd's within its own shell; the second (no cwd) must NOT inherit it.
|
|
37
|
+
execute_bash(command="cd /tmp", cwd="")
|
|
38
|
+
r = execute_bash(command="pwd", cwd="")
|
|
39
|
+
assert os.path.realpath(r["stdout"].strip()) == os.path.realpath(os.getcwd())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_timeout_kills():
|
|
43
|
+
r = execute_bash(command="sleep 5", timeout=1)
|
|
44
|
+
assert r["exit_code"] == 124
|
|
45
|
+
assert "timed out" in (r.get("error") or "").lower()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_env_exported():
|
|
49
|
+
r = execute_bash(command="echo $FOO", env={"FOO": "bar"})
|
|
50
|
+
assert "bar" in r["stdout"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|