akashcli 4.6.7__tar.gz → 4.6.9__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.
- {akashcli-4.6.7 → akashcli-4.6.9}/PKG-INFO +1 -1
- {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/__init__.py +1 -1
- {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/cli.py +555 -47
- {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/relay.py +9 -3
- {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/uploader.py +40 -25
- {akashcli-4.6.7 → akashcli-4.6.9}/akashcli.egg-info/PKG-INFO +1 -1
- {akashcli-4.6.7 → akashcli-4.6.9}/pyproject.toml +1 -1
- {akashcli-4.6.7 → akashcli-4.6.9}/README.md +0 -0
- {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/_net.py +0 -0
- {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/auth.py +0 -0
- {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/client.py +0 -0
- {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/config.py +0 -0
- {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/crypto.py +0 -0
- {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/env_manager.py +0 -0
- {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/exceptions.py +0 -0
- {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/exec.py +0 -0
- {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/integrity.py +0 -0
- {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/logger.py +0 -0
- {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/models.py +0 -0
- {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/p2p.py +0 -0
- {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/packages.py +0 -0
- {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/restore.py +0 -0
- {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/snapshot_crypto.py +0 -0
- {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/storage.py +0 -0
- {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/terminal.py +0 -0
- {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/utils.py +0 -0
- {akashcli-4.6.7 → akashcli-4.6.9}/akashcli.egg-info/SOURCES.txt +0 -0
- {akashcli-4.6.7 → akashcli-4.6.9}/akashcli.egg-info/dependency_links.txt +0 -0
- {akashcli-4.6.7 → akashcli-4.6.9}/akashcli.egg-info/entry_points.txt +0 -0
- {akashcli-4.6.7 → akashcli-4.6.9}/akashcli.egg-info/requires.txt +0 -0
- {akashcli-4.6.7 → akashcli-4.6.9}/akashcli.egg-info/top_level.txt +0 -0
- {akashcli-4.6.7 → akashcli-4.6.9}/setup.cfg +0 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import time
|
|
1
2
|
import os
|
|
2
3
|
import sys
|
|
3
4
|
import json
|
|
@@ -14,6 +15,7 @@ from .client import AkashClient
|
|
|
14
15
|
from .auth import AuthManager
|
|
15
16
|
from .config import ConfigManager
|
|
16
17
|
from .exceptions import AkashError
|
|
18
|
+
from .exec import _device_fingerprint
|
|
17
19
|
from .uploader import VaultUploader
|
|
18
20
|
|
|
19
21
|
# ── ANSI palette (matches exec.py) ───────────────────────────────────────────
|
|
@@ -40,25 +42,37 @@ def _gr(s: str) -> str: return f"{_GR}{s}{_RS}"
|
|
|
40
42
|
|
|
41
43
|
# ── Spinner (single-line, no flicker) ────────────────────────────────────────
|
|
42
44
|
class _Spin:
|
|
43
|
-
"""Minimal inline spinner -
|
|
45
|
+
"""Minimal inline spinner with instant frame-0 rendering, smooth 350ms visibility, and clean cleanup."""
|
|
44
46
|
_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
|
|
45
47
|
|
|
46
|
-
def __init__(self, label: str):
|
|
48
|
+
def __init__(self, label: str, min_seconds: float = 0.35, disabled: bool = False):
|
|
47
49
|
self._label = label
|
|
50
|
+
self._disabled = disabled or (not sys.stdout.isatty())
|
|
51
|
+
self._min_seconds = min_seconds
|
|
48
52
|
self._i = 0
|
|
49
53
|
self._active = False
|
|
50
54
|
self._thread = None
|
|
55
|
+
self._t0 = 0.0
|
|
51
56
|
|
|
52
57
|
def __enter__(self):
|
|
58
|
+
if self._disabled:
|
|
59
|
+
return self
|
|
53
60
|
import threading, time
|
|
54
61
|
self._active = True
|
|
62
|
+
self._t0 = time.monotonic()
|
|
63
|
+
cr = chr(13)
|
|
64
|
+
sys.stdout.write(cr + " " + _C + self._FRAMES[0] + _RS + " " + _DM + self._label + _RS + " ")
|
|
65
|
+
sys.stdout.flush()
|
|
66
|
+
|
|
55
67
|
def _spin():
|
|
68
|
+
time.sleep(0.04)
|
|
56
69
|
while self._active:
|
|
57
70
|
f = self._FRAMES[self._i % len(self._FRAMES)]
|
|
58
|
-
sys.stdout.write(
|
|
71
|
+
sys.stdout.write(chr(13) + " " + _C + f + _RS + " " + _DM + self._label + _RS + " ")
|
|
59
72
|
sys.stdout.flush()
|
|
60
73
|
self._i += 1
|
|
61
|
-
time.sleep(0.
|
|
74
|
+
time.sleep(0.07)
|
|
75
|
+
|
|
62
76
|
self._thread = threading.Thread(target=_spin, daemon=True)
|
|
63
77
|
self._thread.start()
|
|
64
78
|
return self
|
|
@@ -67,22 +81,28 @@ class _Spin:
|
|
|
67
81
|
self._label = label
|
|
68
82
|
|
|
69
83
|
def __exit__(self, *_):
|
|
84
|
+
if self._disabled:
|
|
85
|
+
return
|
|
86
|
+
import time
|
|
87
|
+
elapsed = time.monotonic() - self._t0
|
|
88
|
+
if elapsed < self._min_seconds:
|
|
89
|
+
time.sleep(self._min_seconds - elapsed)
|
|
70
90
|
self._active = False
|
|
71
91
|
if self._thread:
|
|
72
|
-
self._thread.join(timeout=0.
|
|
73
|
-
sys.stdout.write("
|
|
92
|
+
self._thread.join(timeout=0.2)
|
|
93
|
+
sys.stdout.write(chr(13) + chr(27) + "[2K")
|
|
74
94
|
sys.stdout.flush()
|
|
75
95
|
|
|
76
|
-
|
|
77
|
-
def _resolve_client(url_override: str = None, require_key: bool = False):
|
|
96
|
+
def _resolve_client(url_override: str = None, require_key: bool = False, silent: bool = False):
|
|
78
97
|
"""
|
|
79
98
|
Return (api_key_or_None, base_url).
|
|
99
|
+
|
|
80
100
|
require_key=True - hard-fail with a clear message if no key found
|
|
101
|
+
(used by serve/devices which cannot work without server auth).
|
|
81
102
|
require_key=False - return None for api_key if missing; caller shows warning
|
|
103
|
+
and can offer a local/offline fallback.
|
|
82
104
|
"""
|
|
83
105
|
import os as _os
|
|
84
|
-
from urllib.parse import urlparse as _up
|
|
85
|
-
from ._net import patch_dns_globally as _patch_dns
|
|
86
106
|
config = ConfigManager.load()
|
|
87
107
|
base = (
|
|
88
108
|
url_override
|
|
@@ -90,19 +110,19 @@ def _resolve_client(url_override: str = None, require_key: bool = False):
|
|
|
90
110
|
or config.get("api_url", "https://jstore.2bd.net")
|
|
91
111
|
).rstrip("/")
|
|
92
112
|
|
|
93
|
-
host = _up(base).hostname or ""
|
|
94
|
-
if host:
|
|
95
|
-
try: _patch_dns(host)
|
|
96
|
-
except Exception: pass
|
|
97
|
-
|
|
98
113
|
api_key = _os.getenv("AKASH_API_KEY") or config.get("api_key") or None
|
|
99
114
|
|
|
100
115
|
if not api_key and require_key:
|
|
101
|
-
|
|
102
|
-
|
|
116
|
+
if not silent:
|
|
117
|
+
print(f" {_Y}!{_RS} No API key found.")
|
|
118
|
+
print(f" {_DM}Run {_C}akash login{_RS}{_DM} to save your key, or set {_C}AKASH_API_KEY{_RS}{_DM}.{_RS}")
|
|
119
|
+
import sys
|
|
120
|
+
if silent: sys.exit(1)
|
|
103
121
|
raise typer.Exit(1)
|
|
104
122
|
|
|
105
123
|
return api_key, base
|
|
124
|
+
|
|
125
|
+
|
|
106
126
|
_GUEST_WARNING = (
|
|
107
127
|
f" {_Y}⚠{_RS} {_Y}Running as guest{_RS} - not logged in.\n"
|
|
108
128
|
f" {_DM}Features are limited and your data won't be saved to the cloud.\n"
|
|
@@ -143,13 +163,6 @@ def _upload_with_progress(client: AkashClient, tmp_path: str, password=None) ->
|
|
|
143
163
|
# `app` - primary commands (backup / restore / pull / run / shell …)
|
|
144
164
|
# `cloud_app` - file management (akash cloud upload/files/stream/delete/manage/bulk)
|
|
145
165
|
|
|
146
|
-
|
|
147
|
-
def _version_callback(value: bool):
|
|
148
|
-
if value:
|
|
149
|
-
from . import __version__
|
|
150
|
-
print("akashcli v" + __version__)
|
|
151
|
-
raise typer.Exit(0)
|
|
152
|
-
|
|
153
166
|
app = typer.Typer(
|
|
154
167
|
invoke_without_command=True,
|
|
155
168
|
rich_markup_mode=None,
|
|
@@ -166,11 +179,18 @@ def _print_version(value: bool):
|
|
|
166
179
|
print(f"akashcli v{__version__}")
|
|
167
180
|
raise typer.Exit(0)
|
|
168
181
|
|
|
182
|
+
def _version_callback(value: bool):
|
|
183
|
+
if value:
|
|
184
|
+
from . import __version__
|
|
185
|
+
print(f"akashcli v{__version__}")
|
|
186
|
+
raise typer.Exit(0)
|
|
187
|
+
|
|
188
|
+
def raise_exit():
|
|
189
|
+
raise typer.Exit(0)
|
|
190
|
+
|
|
169
191
|
@app.callback(invoke_without_command=True)
|
|
170
192
|
def _main(ctx: typer.Context,
|
|
171
|
-
version: bool = typer.Option(
|
|
172
|
-
callback=_print_version,
|
|
173
|
-
help="Show version and exit."),
|
|
193
|
+
version: Optional[bool] = typer.Option(None, "--version", "-v", callback=_version_callback, is_eager=True, help="Show version and exit."),
|
|
174
194
|
help: bool = typer.Option(False, "--help", "-h", is_eager=True, expose_value=False,
|
|
175
195
|
callback=lambda v: (_print_help(), raise_exit()) if v else None,
|
|
176
196
|
help="Show this message and exit.")):
|
|
@@ -179,8 +199,6 @@ def _main(ctx: typer.Context,
|
|
|
179
199
|
_print_help()
|
|
180
200
|
raise typer.Exit(0)
|
|
181
201
|
|
|
182
|
-
|
|
183
|
-
|
|
184
202
|
def _print_help():
|
|
185
203
|
C, G, Y, DM, RS = _C, _G, _Y, _DM, _RS
|
|
186
204
|
lines = [
|
|
@@ -190,6 +208,7 @@ def _print_help():
|
|
|
190
208
|
f" {DM}ACCOUNT{RS}",
|
|
191
209
|
f" {C}login{RS} {DM}[--label NAME]{RS} save your API key + name this device",
|
|
192
210
|
f" {C}whoami{RS} show account info and storage quota",
|
|
211
|
+
f" {C}version{RS} show CLI and server version",
|
|
193
212
|
f" {C}logout{RS} clear saved credentials",
|
|
194
213
|
f"",
|
|
195
214
|
f" {DM}TERMINAL BACKUP{RS}",
|
|
@@ -276,22 +295,24 @@ def login(
|
|
|
276
295
|
|
|
277
296
|
# ── akash whoami ─────────────────────────────────────────────────────────────
|
|
278
297
|
|
|
279
|
-
|
|
280
298
|
@app.command()
|
|
281
299
|
def version(url: Optional[str] = typer.Option(None, "--url", help="Server URL to check")):
|
|
282
|
-
"""Show akash CLI
|
|
300
|
+
"""Show akash CLI and connected server version with animated status."""
|
|
283
301
|
from . import __version__
|
|
284
302
|
ver_tag = "v" + __version__
|
|
285
303
|
print(f"\n {_C}◈{_RS} {_W}akashcli{_RS} {_cy(ver_tag)}")
|
|
286
304
|
try:
|
|
287
|
-
|
|
288
|
-
|
|
305
|
+
with _Spin("querying server status…"):
|
|
306
|
+
_, base = _resolve_client(url, require_key=False, silent=True)
|
|
307
|
+
resp = httpx.get(f"{base}/health", timeout=8.0)
|
|
289
308
|
if resp.status_code == 200:
|
|
290
309
|
s_ver = resp.json().get("version", "unknown")
|
|
291
|
-
|
|
292
|
-
print(f" {_DM}server {_RS}{_cy(base)} {_dim(
|
|
310
|
+
srv_str = "(" + s_ver + ")"
|
|
311
|
+
print(f" {_DM}server {_RS}{_cy(base)} {_dim(srv_str)}")
|
|
312
|
+
else:
|
|
313
|
+
print(f" {_DM}server {_RS}{_cy(base)} {_dim(f'(HTTP {resp.status_code})')}")
|
|
293
314
|
except Exception:
|
|
294
|
-
|
|
315
|
+
print(f" {_DM}server {_RS}{_cy(base)} {_dim('(unreachable)')}")
|
|
295
316
|
print()
|
|
296
317
|
|
|
297
318
|
@app.command()
|
|
@@ -352,7 +373,8 @@ def cloud_files(raw: bool = typer.Option(False, "--json", help="Raw JSON output"
|
|
|
352
373
|
"""List all files stored in the cloud."""
|
|
353
374
|
try:
|
|
354
375
|
client = AkashClient()
|
|
355
|
-
|
|
376
|
+
with _Spin("fetching cloud vault files…", disabled=raw):
|
|
377
|
+
file_list = client.list_files()
|
|
356
378
|
|
|
357
379
|
if raw:
|
|
358
380
|
print(json.dumps([f.__dict__ for f in file_list], indent=2))
|
|
@@ -724,7 +746,8 @@ def saves(raw: bool = typer.Option(False, "--json", help="Raw JSON output")):
|
|
|
724
746
|
|
|
725
747
|
try:
|
|
726
748
|
client = AkashClient()
|
|
727
|
-
|
|
749
|
+
with _Spin("querying cloud saves…", disabled=raw):
|
|
750
|
+
snaps = list_snapshots(client)
|
|
728
751
|
|
|
729
752
|
if not snaps:
|
|
730
753
|
_info("no saves found - run `akash backup` to create one")
|
|
@@ -790,7 +813,8 @@ def pull(
|
|
|
790
813
|
params["password"] = password
|
|
791
814
|
|
|
792
815
|
try:
|
|
793
|
-
|
|
816
|
+
with _Spin(f"resolving snapshot {code}…"):
|
|
817
|
+
resp = httpx.get(f"{base}/api/recover/{code}", params=params, timeout=15)
|
|
794
818
|
except Exception as e:
|
|
795
819
|
_err(f"could not reach server: {e}")
|
|
796
820
|
raise typer.Exit(1)
|
|
@@ -935,14 +959,15 @@ def check(
|
|
|
935
959
|
"""
|
|
936
960
|
if seal:
|
|
937
961
|
from .integrity import seal as _seal, _manifest_path
|
|
938
|
-
|
|
939
|
-
|
|
962
|
+
with _Spin("sealing package source files…"):
|
|
963
|
+
hashes = _seal()
|
|
940
964
|
_ok(f"hashed {len(hashes)} source files")
|
|
941
965
|
_ok(f"manifest → {_dim(str(_manifest_path()))}")
|
|
942
966
|
_info("tamper detection active for backup, restore, and pull")
|
|
943
967
|
else:
|
|
944
968
|
from .integrity import status as _status
|
|
945
|
-
|
|
969
|
+
with _Spin("verifying source code integrity…"):
|
|
970
|
+
s = _status()
|
|
946
971
|
if not s["sealed"]:
|
|
947
972
|
_warn(f"not sealed yet - run {_cy('akash check --seal')} to enable tamper detection")
|
|
948
973
|
return
|
|
@@ -1010,9 +1035,14 @@ def run(
|
|
|
1010
1035
|
from .relay import connect_run
|
|
1011
1036
|
_info(f"running on device {_cy(device or 'auto')}: {command}")
|
|
1012
1037
|
_p()
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1038
|
+
spin = _Spin(f"connecting to relay ({device or 'auto'})…")
|
|
1039
|
+
spin.__enter__()
|
|
1040
|
+
try:
|
|
1041
|
+
def _on_connected(dev):
|
|
1042
|
+
spin.__exit__(None, None, None)
|
|
1043
|
+
exit_code = connect_run(api_key, base, command, target=device or None, on_connected=_on_connected)
|
|
1044
|
+
finally:
|
|
1045
|
+
spin.__exit__(None, None, None)
|
|
1016
1046
|
_p()
|
|
1017
1047
|
if exit_code == 0:
|
|
1018
1048
|
_info("exited 0")
|
|
@@ -1202,13 +1232,20 @@ def shell(
|
|
|
1202
1232
|
label = device.strip() if device.strip() else None
|
|
1203
1233
|
_p()
|
|
1204
1234
|
_info(f"connecting to {_cy(label or 'auto')}…")
|
|
1235
|
+
spin = _Spin(f"connecting to relay ({label or 'auto'})…")
|
|
1236
|
+
spin.__enter__()
|
|
1205
1237
|
try:
|
|
1206
|
-
|
|
1238
|
+
def _on_connected(dev):
|
|
1239
|
+
spin.__exit__(None, None, None)
|
|
1240
|
+
exit_code = connect_shell(api_key, base, target=label, on_connected=_on_connected)
|
|
1207
1241
|
except RuntimeError as e:
|
|
1242
|
+
spin.__exit__(None, None, None)
|
|
1208
1243
|
_err(str(e))
|
|
1209
1244
|
_p()
|
|
1210
1245
|
_info(f"Make sure the device is running: {_cy('akash serve')}")
|
|
1211
1246
|
raise typer.Exit(1)
|
|
1247
|
+
finally:
|
|
1248
|
+
spin.__exit__(None, None, None)
|
|
1212
1249
|
_p()
|
|
1213
1250
|
_info("session closed")
|
|
1214
1251
|
if exit_code != 0:
|
|
@@ -1272,7 +1309,7 @@ def devices(
|
|
|
1272
1309
|
akash serve register it as reachable
|
|
1273
1310
|
"""
|
|
1274
1311
|
try:
|
|
1275
|
-
api_key, base = _resolve_client(require_key=False)
|
|
1312
|
+
api_key, base = _resolve_client(url, require_key=False)
|
|
1276
1313
|
except typer.Exit:
|
|
1277
1314
|
raise
|
|
1278
1315
|
except Exception as e:
|
|
@@ -2320,3 +2357,474 @@ def env_pull(
|
|
|
2320
2357
|
|
|
2321
2358
|
if __name__ == "__main__":
|
|
2322
2359
|
app()
|
|
2360
|
+
|
|
2361
|
+
|
|
2362
|
+
# =============================================================================
|
|
2363
|
+
# AKASH AGENT & DEVICE BINDING COMMANDS
|
|
2364
|
+
# =============================================================================
|
|
2365
|
+
|
|
2366
|
+
agent_app = typer.Typer(help="Manage AI agents, client credentials & device approvals.")
|
|
2367
|
+
app.add_typer(agent_app, name="agent", help="agent credentials · device binding · approvals")
|
|
2368
|
+
|
|
2369
|
+
def _render_inspection_card(r: dict) -> None:
|
|
2370
|
+
print(f"\n {_C}╭─ ⚡ INCOMING AGENT DEVICE REGISTRATION ────────────────────────────{_RS}")
|
|
2371
|
+
print(f" {_GR}│{_RS} {_DM}Request ID :{_RS} {_ye(r['request_id'])}")
|
|
2372
|
+
print(f" {_GR}│{_RS} {_DM}Client Name :{_RS} {_W}{r['client_name']}{_RS}")
|
|
2373
|
+
print(f" {_GR}│{_RS} {_DM}Client ID :{_RS} {_dim(r['client_id'])}")
|
|
2374
|
+
print(f" {_GR}│{_RS} {_DM}Device Hostname:{_RS} {_W}{r['device_hostname']}{_RS}")
|
|
2375
|
+
print(f" {_GR}│{_RS} {_DM}Hardware ID :{_RS} {_C}{r['device_fingerprint'][:24]}…{_RS}")
|
|
2376
|
+
print(f" {_GR}│{_RS} {_DM}IP Address :{_RS} {_W}{r['ip_address']}{_RS}")
|
|
2377
|
+
print(f" {_GR}│{_RS} {_DM}Location :{_RS} {_W}{r['city']}, {r['country']}{_RS} {_dim('(' + r['isp'] + ')')}")
|
|
2378
|
+
print(f" {_GR}│{_RS} {_DM}OS / Platform :{_RS} {r['device_platform']}")
|
|
2379
|
+
print(f" {_GR}│{_RS} {_DM}Target Device :{_RS} {_ye(r['target_device'])}")
|
|
2380
|
+
print(f" {_GR}│{_RS} {_DM}Scopes :{_RS} {r['requested_scopes']}")
|
|
2381
|
+
print(f" {_GR}│{_RS} {_DM}Timestamp :{_RS} {_dim(r['created_at'])}")
|
|
2382
|
+
print(f" {_C}╰────────────────────────────────────────────────────────────────────{_RS}")
|
|
2383
|
+
|
|
2384
|
+
@agent_app.command("create")
|
|
2385
|
+
def agent_create(
|
|
2386
|
+
name: str = typer.Option("Cursor IDE", "--name", "-n", help="Name of the AI Agent / Application"),
|
|
2387
|
+
ttl: int = typer.Option(7, "--ttl", "-t", help="Validity in days (e.g. 7)"),
|
|
2388
|
+
scopes: str = typer.Option("workspace:read,relay:exec", "--scopes", "-s", help="Comma-separated scopes"),
|
|
2389
|
+
devices: str = typer.Option("*", "--devices", "-d", help="Allowed device labels (e.g. 'termux,laptop' or '*')"),
|
|
2390
|
+
url: Optional[str] = typer.Option(None, "--url", help="Override server URL"),
|
|
2391
|
+
):
|
|
2392
|
+
"""Generate new Client ID and Client Secret with hardware binding."""
|
|
2393
|
+
api_key, base = _resolve_client(url, require_key=False)
|
|
2394
|
+
hdrs = {"Origin": "https://akash.cli", "x-device-id": _device_fingerprint()}
|
|
2395
|
+
if api_key: hdrs["x-api-key"] = api_key
|
|
2396
|
+
with _Spin("generating agent credentials…"):
|
|
2397
|
+
res = httpx.post(
|
|
2398
|
+
f"{base}/api/agent/app",
|
|
2399
|
+
headers=hdrs,
|
|
2400
|
+
json={"name": name, "ttl_days": ttl, "scopes": scopes, "allowed_devices": devices},
|
|
2401
|
+
timeout=10.0
|
|
2402
|
+
)
|
|
2403
|
+
if res.status_code != 200:
|
|
2404
|
+
_err(f"Failed to create agent credentials: {res.text}")
|
|
2405
|
+
raise typer.Exit(1)
|
|
2406
|
+
|
|
2407
|
+
d = res.json()
|
|
2408
|
+
|
|
2409
|
+
import json, os, secrets
|
|
2410
|
+
from pathlib import Path
|
|
2411
|
+
agents_file = Path.home() / ".akash" / "agents.json"
|
|
2412
|
+
agents_file.parent.mkdir(parents=True, exist_ok=True)
|
|
2413
|
+
try:
|
|
2414
|
+
agents = json.loads(agents_file.read_text())
|
|
2415
|
+
except Exception:
|
|
2416
|
+
agents = {}
|
|
2417
|
+
|
|
2418
|
+
profile_id = secrets.token_hex(2)
|
|
2419
|
+
agents[profile_id] = {
|
|
2420
|
+
"client_id": d["client_id"],
|
|
2421
|
+
"client_secret": d["client_secret"],
|
|
2422
|
+
"name": name
|
|
2423
|
+
}
|
|
2424
|
+
agents_file.write_text(json.dumps(agents, indent=2))
|
|
2425
|
+
try: agents_file.chmod(0o600)
|
|
2426
|
+
except OSError: pass
|
|
2427
|
+
|
|
2428
|
+
_p()
|
|
2429
|
+
_ok(f"Agent Credentials Issued & Saved Locally: {_cy(name)}")
|
|
2430
|
+
print(f" {_DM}Profile ID :{_RS} {_C}{profile_id}{_RS}")
|
|
2431
|
+
print(f" {_DM}Client ID :{_RS} {_ye(d['client_id'])}")
|
|
2432
|
+
print(f" {_DM}Client Secret :{_RS} {_W}{d['client_secret']}{_RS}")
|
|
2433
|
+
print(f" {_DM}Expires At :{_RS} {_dim(d['expires_at'][:19].replace('T', ' '))} UTC ({ttl} days)")
|
|
2434
|
+
_p()
|
|
2435
|
+
_info("To use in Cursor or Claude Desktop, add a Command MCP Server with:")
|
|
2436
|
+
if url:
|
|
2437
|
+
print(f" {_G}akash mcp serve --id {profile_id} --url {url}{_RS}")
|
|
2438
|
+
else:
|
|
2439
|
+
print(f" {_G}akash mcp serve --id {profile_id}{_RS}")
|
|
2440
|
+
_p()
|
|
2441
|
+
|
|
2442
|
+
@agent_app.command("requests")
|
|
2443
|
+
def agent_requests():
|
|
2444
|
+
"""List pending device registration requests."""
|
|
2445
|
+
api_key, base = _resolve_client(url, require_key=False)
|
|
2446
|
+
hdrs = {"Origin": "https://akash.cli", "x-device-id": _device_fingerprint()}
|
|
2447
|
+
if api_key: hdrs["x-api-key"] = api_key
|
|
2448
|
+
with _Spin("checking pending device requests…"):
|
|
2449
|
+
res = httpx.get(
|
|
2450
|
+
f"{base}/api/agent/requests",
|
|
2451
|
+
headers=hdrs,
|
|
2452
|
+
timeout=10.0
|
|
2453
|
+
)
|
|
2454
|
+
if res.status_code != 200:
|
|
2455
|
+
_err(f"Failed: {res.text}")
|
|
2456
|
+
raise typer.Exit(1)
|
|
2457
|
+
|
|
2458
|
+
reqs = res.json().get("requests", [])
|
|
2459
|
+
if not reqs:
|
|
2460
|
+
_info("no device registration requests found")
|
|
2461
|
+
return
|
|
2462
|
+
|
|
2463
|
+
_p()
|
|
2464
|
+
print(f" {_C}◈{_RS} {_W}Agent Device Registrations{_RS} {_dim(f'({len(reqs)})')}")
|
|
2465
|
+
_p()
|
|
2466
|
+
print(f" {_DM}{'ID':<12} {'AGENT':<16} {'IP':<15} {'LOCATION':<20} STATUS{_RS}")
|
|
2467
|
+
print(f" {_GR}{'─'*72}{_RS}")
|
|
2468
|
+
for r in reqs:
|
|
2469
|
+
st_color = _G if r["status"] == "approved" else (_Y if r["status"] == "pending" else _R)
|
|
2470
|
+
loc = f"{r['city']}, {r['country']}"
|
|
2471
|
+
print(f" {_ye(r['request_id']):<21} {_W}{r['client_name'][:15]:<16}{_RS} {r['ip_address']:<15} {loc[:19]:<20} {st_color}{r['status'].upper()}{_RS}")
|
|
2472
|
+
_p()
|
|
2473
|
+
_info("Inspect details: akash agent inspect <ID>")
|
|
2474
|
+
_info("Approve access: akash agent approve <ID>")
|
|
2475
|
+
|
|
2476
|
+
@agent_app.command("inspect")
|
|
2477
|
+
def agent_inspect(request_id: str):
|
|
2478
|
+
"""View full contextual intelligence card for a request."""
|
|
2479
|
+
api_key, base = _resolve_client(url, require_key=False)
|
|
2480
|
+
hdrs = {"Origin": "https://akash.cli", "x-device-id": _device_fingerprint()}
|
|
2481
|
+
if api_key: hdrs["x-api-key"] = api_key
|
|
2482
|
+
with _Spin(f"inspecting {request_id}…"):
|
|
2483
|
+
res = httpx.get(
|
|
2484
|
+
f"{base}/api/agent/requests",
|
|
2485
|
+
headers=hdrs,
|
|
2486
|
+
timeout=10.0
|
|
2487
|
+
)
|
|
2488
|
+
reqs = res.json().get("requests", [])
|
|
2489
|
+
match = next((r for r in reqs if r["request_id"] == request_id), None)
|
|
2490
|
+
if not match:
|
|
2491
|
+
_err(f"Request ID '{request_id}' not found.")
|
|
2492
|
+
raise typer.Exit(1)
|
|
2493
|
+
_render_inspection_card(match)
|
|
2494
|
+
|
|
2495
|
+
@agent_app.command("approve")
|
|
2496
|
+
def agent_approve(request_id: str):
|
|
2497
|
+
"""Approve a device registration directly from your terminal."""
|
|
2498
|
+
api_key, base = _resolve_client(url, require_key=False)
|
|
2499
|
+
hdrs = {"Origin": "https://akash.cli", "x-device-id": _device_fingerprint()}
|
|
2500
|
+
if api_key: hdrs["x-api-key"] = api_key
|
|
2501
|
+
with _Spin(f"approving {request_id}…"):
|
|
2502
|
+
res = httpx.post(
|
|
2503
|
+
f"{base}/api/agent/requests/{request_id}/approve",
|
|
2504
|
+
headers=hdrs,
|
|
2505
|
+
timeout=10.0
|
|
2506
|
+
)
|
|
2507
|
+
if res.status_code == 200:
|
|
2508
|
+
_ok(f"Device request {_ye(request_id)} APPROVED! Access token released to agent.")
|
|
2509
|
+
else:
|
|
2510
|
+
_err(f"Approval failed: {res.text}")
|
|
2511
|
+
|
|
2512
|
+
@agent_app.command("reject")
|
|
2513
|
+
def agent_reject(request_id: str):
|
|
2514
|
+
"""Reject a device registration request."""
|
|
2515
|
+
api_key, base = _resolve_client(url, require_key=False)
|
|
2516
|
+
hdrs = {"Origin": "https://akash.cli", "x-device-id": _device_fingerprint()}
|
|
2517
|
+
if api_key: hdrs["x-api-key"] = api_key
|
|
2518
|
+
with _Spin(f"rejecting {request_id}…"):
|
|
2519
|
+
res = httpx.post(
|
|
2520
|
+
f"{base}/api/agent/requests/{request_id}/reject",
|
|
2521
|
+
headers=hdrs,
|
|
2522
|
+
timeout=10.0
|
|
2523
|
+
)
|
|
2524
|
+
if res.status_code == 200:
|
|
2525
|
+
_ok(f"Device request {_ye(request_id)} REJECTED.")
|
|
2526
|
+
else:
|
|
2527
|
+
_err(f"Rejection failed: {res.text}")
|
|
2528
|
+
|
|
2529
|
+
@app.command("guard")
|
|
2530
|
+
def guard_console(url: Optional[str] = typer.Option(None, "--url", help="Override server URL")):
|
|
2531
|
+
import time
|
|
2532
|
+
"""Interactive real-time terminal console that listens for agent access requests."""
|
|
2533
|
+
api_key, base = _resolve_client(url, require_key=False)
|
|
2534
|
+
hdrs = {"Origin": "https://akash.cli", "x-device-id": _device_fingerprint()}
|
|
2535
|
+
if api_key: hdrs["x-api-key"] = api_key
|
|
2536
|
+
print(f"\n {_G}◉{_RS} {_W}Akash Guard Console Online{_RS}")
|
|
2537
|
+
print(f" {_DM}Listening for incoming agent device registrations (Ctrl-C to stop)…{_RS}\n")
|
|
2538
|
+
|
|
2539
|
+
handled_requests = set()
|
|
2540
|
+
while True:
|
|
2541
|
+
try:
|
|
2542
|
+
res = httpx.get(
|
|
2543
|
+
f"{base}/api/agent/requests",
|
|
2544
|
+
headers=hdrs,
|
|
2545
|
+
timeout=5.0
|
|
2546
|
+
)
|
|
2547
|
+
if res.status_code == 200:
|
|
2548
|
+
for r in res.json().get("requests", []):
|
|
2549
|
+
rid = r["request_id"]
|
|
2550
|
+
if r["status"] == "pending" and rid not in handled_requests:
|
|
2551
|
+
_render_inspection_card(r)
|
|
2552
|
+
try:
|
|
2553
|
+
sys.stdout.write(f" Approve access for this device? [y/N]: ")
|
|
2554
|
+
sys.stdout.flush()
|
|
2555
|
+
choice = sys.stdin.readline().strip().lower()
|
|
2556
|
+
except (KeyboardInterrupt, EOFError):
|
|
2557
|
+
choice = "n"
|
|
2558
|
+
|
|
2559
|
+
if choice == "y":
|
|
2560
|
+
httpx.post(f"{base}/api/agent/requests/{rid}/approve", headers=hdrs, timeout=5)
|
|
2561
|
+
_ok(f"Approved {rid} - Access token released to agent!\n")
|
|
2562
|
+
else:
|
|
2563
|
+
httpx.post(f"{base}/api/agent/requests/{rid}/reject", headers=hdrs, timeout=5)
|
|
2564
|
+
_warn(f"Rejected {rid}.\n")
|
|
2565
|
+
handled_requests.add(rid)
|
|
2566
|
+
time.sleep(2.0)
|
|
2567
|
+
except KeyboardInterrupt:
|
|
2568
|
+
print(f"\n {_DM}Guard console stopped.{_RS}\n")
|
|
2569
|
+
break
|
|
2570
|
+
except Exception:
|
|
2571
|
+
time.sleep(3.0)
|
|
2572
|
+
|
|
2573
|
+
|
|
2574
|
+
# =============================================================================
|
|
2575
|
+
# MODEL CONTEXT PROTOCOL (MCP) SERVER FOR AI AGENTS (Cursor, Claude, AutoGen)
|
|
2576
|
+
# =============================================================================
|
|
2577
|
+
|
|
2578
|
+
mcp_app = typer.Typer(help="Model Context Protocol (MCP) server for AI agents.")
|
|
2579
|
+
app.add_typer(mcp_app, name="mcp", help="Model Context Protocol (MCP) bridge for AI agents")
|
|
2580
|
+
|
|
2581
|
+
|
|
2582
|
+
def _sanitize_agent_command(command: str) -> tuple[bool, str]:
|
|
2583
|
+
"""Inspects command for destructive patterns, exfiltration pipes, and jail escapes using Regex."""
|
|
2584
|
+
import re
|
|
2585
|
+
cmd = command.strip()
|
|
2586
|
+
cmd_lower = cmd.lower()
|
|
2587
|
+
|
|
2588
|
+
# 1. Block destructive system wipe commands (e.g. rm -rf /, rm -r -f ~)
|
|
2589
|
+
if re.search(r'rm\s+-[a-zA-Z\s]*[rf]+[a-zA-Z\s]*\s+(/|/\*|~|~\/\*)(?:\s|$)', cmd_lower):
|
|
2590
|
+
return False, "CRITICAL SECURITY BLOCK: Destructive system command rejected (rm -rf root/home)."
|
|
2591
|
+
|
|
2592
|
+
FORBIDDEN_BINARIES = [
|
|
2593
|
+
":(){ :|:& };:", "mkfs", "dd if=", "> /dev/sda", "> /dev/nvme",
|
|
2594
|
+
"shutdown", "reboot", "init 0", "init 6"
|
|
2595
|
+
]
|
|
2596
|
+
for fb in FORBIDDEN_BINARIES:
|
|
2597
|
+
if fb in cmd_lower:
|
|
2598
|
+
return False, f"CRITICAL SECURITY BLOCK: Dangerous system command rejected: '{fb}'"
|
|
2599
|
+
|
|
2600
|
+
# 2. Block reverse shells and unverified network pipes (curl/wget ... | bash)
|
|
2601
|
+
if re.search(r'(curl|wget)\b.*?\|\s*(bash|sh|zsh)\b', cmd_lower):
|
|
2602
|
+
return False, "SECURITY BLOCK: Unauthorized network execution pipe rejected."
|
|
2603
|
+
|
|
2604
|
+
NETWORK_EXFIL = ["/dev/tcp/", "/dev/udp/", "nc -e", "ncat -e", "netcat -e"]
|
|
2605
|
+
for np in NETWORK_EXFIL:
|
|
2606
|
+
if np in cmd_lower:
|
|
2607
|
+
return False, f"SECURITY BLOCK: Potential reverse shell rejected: '{np}'"
|
|
2608
|
+
|
|
2609
|
+
# 3. Block access to private credential directories
|
|
2610
|
+
CREDENTIAL_TARGETS = [
|
|
2611
|
+
".ssh", ".aws", ".akash/machine.key", ".akash/config.enc",
|
|
2612
|
+
"/etc/shadow", "/etc/sudoers"
|
|
2613
|
+
]
|
|
2614
|
+
for ct in CREDENTIAL_TARGETS:
|
|
2615
|
+
if ct in cmd_lower:
|
|
2616
|
+
return False, f"SECURITY BLOCK: Access to sensitive credential path rejected: '{ct}'"
|
|
2617
|
+
|
|
2618
|
+
return True, ""
|
|
2619
|
+
|
|
2620
|
+
def _redact_agent_output(output: str) -> str:
|
|
2621
|
+
"""Masks database passwords, private keys, and API tokens before returning to AI."""
|
|
2622
|
+
from .terminal import sanitize_secret_text
|
|
2623
|
+
return sanitize_secret_text(output)
|
|
2624
|
+
|
|
2625
|
+
@mcp_app.command("serve")
|
|
2626
|
+
def mcp_serve(
|
|
2627
|
+
url: Optional[str] = typer.Option(None, "--url", help="Override server URL"),
|
|
2628
|
+
client_id: str = typer.Option(None, "--client-id", help="Agent Client ID"),
|
|
2629
|
+
client_secret: str = typer.Option(None, "--client-secret", help="Agent Client Secret"),
|
|
2630
|
+
profile_id: str = typer.Option(None, "--id", help="Local Agent Profile ID")
|
|
2631
|
+
):
|
|
2632
|
+
"""Runs standard Model Context Protocol (MCP) JSON-RPC 2.0 server over stdio for AI agents."""
|
|
2633
|
+
import sys, json, platform
|
|
2634
|
+
_, base = _resolve_client(url, require_key=False)
|
|
2635
|
+
|
|
2636
|
+
if profile_id:
|
|
2637
|
+
from pathlib import Path
|
|
2638
|
+
try:
|
|
2639
|
+
agents = json.loads((Path.home() / ".akash" / "agents.json").read_text())
|
|
2640
|
+
if profile_id in agents:
|
|
2641
|
+
client_id = agents[profile_id]["client_id"]
|
|
2642
|
+
client_secret = agents[profile_id]["client_secret"]
|
|
2643
|
+
except Exception:
|
|
2644
|
+
pass
|
|
2645
|
+
|
|
2646
|
+
cid = client_id or os.getenv("AKASH_CLIENT_ID")
|
|
2647
|
+
csec = client_secret or os.getenv("AKASH_CLIENT_SECRET")
|
|
2648
|
+
|
|
2649
|
+
if not cid or not csec:
|
|
2650
|
+
sys.stderr.write("\r\n[MCP ERROR] Missing Client ID or Secret. Please pass --id, or --client-id and --client-secret.\r\n")
|
|
2651
|
+
sys.exit(1)
|
|
2652
|
+
|
|
2653
|
+
# 1. Automatic Handshake & Knock
|
|
2654
|
+
dev_fp = _device_fingerprint()
|
|
2655
|
+
hostname = platform.node() or "unknown-host"
|
|
2656
|
+
os_info = f"{platform.system()} {platform.release()} {platform.machine()}"
|
|
2657
|
+
|
|
2658
|
+
token = None
|
|
2659
|
+
# Check if this machine is already approved or needs to knock
|
|
2660
|
+
try:
|
|
2661
|
+
res = httpx.post(f"{base}/api/agent/token", json={
|
|
2662
|
+
"client_id": cid,
|
|
2663
|
+
"client_secret": csec,
|
|
2664
|
+
"device_fingerprint": dev_fp,
|
|
2665
|
+
"hostname": hostname,
|
|
2666
|
+
"platform": os_info,
|
|
2667
|
+
"target_device": "*"
|
|
2668
|
+
}, timeout=5.0)
|
|
2669
|
+
|
|
2670
|
+
if res.status_code == 202:
|
|
2671
|
+
# Device needs human approval in terminal
|
|
2672
|
+
req_id = res.json().get("request_id")
|
|
2673
|
+
sys.stderr.write(f"\r\n[MCP GATE] Device '{hostname}' ({dev_fp[:16]}...) waiting for approval in 'akash guard' terminal...\r\n")
|
|
2674
|
+
sys.stderr.flush()
|
|
2675
|
+
|
|
2676
|
+
# Poll until human types 'y' in akash guard
|
|
2677
|
+
while not token:
|
|
2678
|
+
time.sleep(1.5)
|
|
2679
|
+
poll_res = httpx.get(f"{base}/api/agent/token/poll/{req_id}", timeout=5.0)
|
|
2680
|
+
if poll_res.status_code == 200:
|
|
2681
|
+
d = poll_res.json()
|
|
2682
|
+
if d.get("status") == "approved":
|
|
2683
|
+
token = d.get("access_token")
|
|
2684
|
+
sys.stderr.write("\r\n[MCP GATE] Device approved! AI Agent connection active.\r\n")
|
|
2685
|
+
sys.stderr.flush()
|
|
2686
|
+
break
|
|
2687
|
+
elif d.get("status") == "rejected":
|
|
2688
|
+
sys.stderr.write("\r\n[MCP GATE] Device rejected by human in Akash Guard.\r\n")
|
|
2689
|
+
sys.stderr.flush()
|
|
2690
|
+
sys.exit(1)
|
|
2691
|
+
elif res.status_code == 200:
|
|
2692
|
+
token = res.json().get("access_token")
|
|
2693
|
+
except Exception as e:
|
|
2694
|
+
sys.stderr.write(f"\r\n[MCP NOTICE] Operating in local sandbox mode ({e})\r\n")
|
|
2695
|
+
|
|
2696
|
+
# 2. Standard MCP Tools Declaration
|
|
2697
|
+
TOOLS = [
|
|
2698
|
+
{
|
|
2699
|
+
"name": "akash_exec_command",
|
|
2700
|
+
"description": "Execute a shell command inside the project workspace. Output is bounded and sanitized.",
|
|
2701
|
+
"inputSchema": {
|
|
2702
|
+
"type": "object",
|
|
2703
|
+
"properties": {
|
|
2704
|
+
"command": {"type": "string", "description": "Command to run (e.g. 'pytest', 'ls -la', 'python3 main.py')"},
|
|
2705
|
+
"cwd": {"type": "string", "description": "Directory relative to workspace root", "default": "."}
|
|
2706
|
+
},
|
|
2707
|
+
"required": ["command"]
|
|
2708
|
+
}
|
|
2709
|
+
},
|
|
2710
|
+
{
|
|
2711
|
+
"name": "akash_checkpoint_create",
|
|
2712
|
+
"description": "Create an encrypted rollback checkpoint in Akash Vault before editing files.",
|
|
2713
|
+
"inputSchema": {
|
|
2714
|
+
"type": "object",
|
|
2715
|
+
"properties": {
|
|
2716
|
+
"label": {"type": "string", "description": "Checkpoint name (e.g. 'pre_refactor')"}
|
|
2717
|
+
},
|
|
2718
|
+
"required": ["label"]
|
|
2719
|
+
}
|
|
2720
|
+
},
|
|
2721
|
+
{
|
|
2722
|
+
"name": "akash_checkpoint_restore",
|
|
2723
|
+
"description": "Roll back project workspace to a previous checkpoint code if mistakes were made.",
|
|
2724
|
+
"inputSchema": {
|
|
2725
|
+
"type": "object",
|
|
2726
|
+
"properties": {
|
|
2727
|
+
"code": {"type": "string", "description": "Vault code to roll back to"}
|
|
2728
|
+
},
|
|
2729
|
+
"required": ["code"]
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
]
|
|
2733
|
+
|
|
2734
|
+
# 3. JSON-RPC 2.0 stdio message loop (Standard MCP protocol)
|
|
2735
|
+
while True:
|
|
2736
|
+
try:
|
|
2737
|
+
line = sys.stdin.readline()
|
|
2738
|
+
if not line:
|
|
2739
|
+
break
|
|
2740
|
+
msg = json.loads(line.strip())
|
|
2741
|
+
msg_id = msg.get("id")
|
|
2742
|
+
method = msg.get("method")
|
|
2743
|
+
params = msg.get("params", {})
|
|
2744
|
+
|
|
2745
|
+
# JSON-RPC 2.0 Specification: Do NOT respond to Notifications (id is None)
|
|
2746
|
+
if "id" not in msg or msg_id is None:
|
|
2747
|
+
continue
|
|
2748
|
+
|
|
2749
|
+
if method == "initialize":
|
|
2750
|
+
reply = {
|
|
2751
|
+
"jsonrpc": "2.0",
|
|
2752
|
+
"id": msg_id,
|
|
2753
|
+
"result": {
|
|
2754
|
+
"protocolVersion": "2024-11-05",
|
|
2755
|
+
"serverInfo": {"name": "akash-mcp", "version": "4.6.7"},
|
|
2756
|
+
"capabilities": {"tools": {}}
|
|
2757
|
+
}
|
|
2758
|
+
}
|
|
2759
|
+
elif method == "tools/list":
|
|
2760
|
+
reply = {
|
|
2761
|
+
"jsonrpc": "2.0",
|
|
2762
|
+
"id": msg_id,
|
|
2763
|
+
"result": {"tools": TOOLS}
|
|
2764
|
+
}
|
|
2765
|
+
elif method == "tools/call":
|
|
2766
|
+
tool_name = params.get("name")
|
|
2767
|
+
args = params.get("arguments", {})
|
|
2768
|
+
|
|
2769
|
+
if tool_name == "akash_exec_command":
|
|
2770
|
+
cmd = args.get("command", "")
|
|
2771
|
+
|
|
2772
|
+
# 1. Pass through Security Firewall
|
|
2773
|
+
is_safe, sec_reason = _sanitize_agent_command(cmd)
|
|
2774
|
+
if not is_safe:
|
|
2775
|
+
sys.stderr.write(f"\r\n🚨 [FIREWALL BLOCKED] {sec_reason}\r\n")
|
|
2776
|
+
sys.stderr.flush()
|
|
2777
|
+
reply = {
|
|
2778
|
+
"jsonrpc": "2.0",
|
|
2779
|
+
"id": msg_id,
|
|
2780
|
+
"result": {
|
|
2781
|
+
"content": [{"type": "text", "text": f"⛔ {sec_reason}"}],
|
|
2782
|
+
"isError": True
|
|
2783
|
+
}
|
|
2784
|
+
}
|
|
2785
|
+
else:
|
|
2786
|
+
# 2. Run safely inside workspace with timeout & secret redaction
|
|
2787
|
+
import subprocess
|
|
2788
|
+
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=60)
|
|
2789
|
+
raw_out = r.stdout + r.stderr
|
|
2790
|
+
sanitized_out = _redact_agent_output(raw_out)
|
|
2791
|
+
|
|
2792
|
+
# Bounded output ceiling (32 KB max to prevent LLM token flooding)
|
|
2793
|
+
if len(sanitized_out) > 32768:
|
|
2794
|
+
sanitized_out = sanitized_out[:32768] + "\n... [Output truncated at 32 KB to protect context window]"
|
|
2795
|
+
|
|
2796
|
+
reply = {
|
|
2797
|
+
"jsonrpc": "2.0",
|
|
2798
|
+
"id": msg_id,
|
|
2799
|
+
"result": {
|
|
2800
|
+
"content": [{"type": "text", "text": sanitized_out or "(no output)"}],
|
|
2801
|
+
"isError": r.returncode != 0
|
|
2802
|
+
}
|
|
2803
|
+
}
|
|
2804
|
+
elif tool_name == "akash_checkpoint_create":
|
|
2805
|
+
label = args.get("label", "agent_checkpoint")
|
|
2806
|
+
from .terminal import build_snapshot
|
|
2807
|
+
archive_bytes, manifest, name = build_snapshot(label=label)
|
|
2808
|
+
reply = {
|
|
2809
|
+
"jsonrpc": "2.0",
|
|
2810
|
+
"id": msg_id,
|
|
2811
|
+
"result": {
|
|
2812
|
+
"content": [{"type": "text", "text": f"Checkpoint '{label}' created successfully ({len(manifest.get('files', []))} files tracked)."}]
|
|
2813
|
+
}
|
|
2814
|
+
}
|
|
2815
|
+
else:
|
|
2816
|
+
reply = {
|
|
2817
|
+
"jsonrpc": "2.0",
|
|
2818
|
+
"id": msg_id,
|
|
2819
|
+
"error": {"code": -32601, "message": f"Tool '{tool_name}' not found."}
|
|
2820
|
+
}
|
|
2821
|
+
else:
|
|
2822
|
+
reply = {"jsonrpc": "2.0", "id": msg_id, "result": {}}
|
|
2823
|
+
|
|
2824
|
+
sys.stdout.write(json.dumps(reply) + "\n")
|
|
2825
|
+
sys.stdout.flush()
|
|
2826
|
+
|
|
2827
|
+
except Exception as e:
|
|
2828
|
+
err_reply = {"jsonrpc": "2.0", "id": None, "error": {"code": -32603, "message": str(e)}}
|
|
2829
|
+
sys.stdout.write(json.dumps(err_reply) + "\n")
|
|
2830
|
+
sys.stdout.flush()
|
|
@@ -25,7 +25,7 @@ Wire protocol (all JSON frames over the relay WebSocket):
|
|
|
25
25
|
|
|
26
26
|
from __future__ import annotations
|
|
27
27
|
|
|
28
|
-
AKASH_VERSION = "4.6.
|
|
28
|
+
AKASH_VERSION = "4.6.9"
|
|
29
29
|
|
|
30
30
|
import base64
|
|
31
31
|
import json
|
|
@@ -424,7 +424,8 @@ def _handle_pty(ws, frame: dict) -> None:
|
|
|
424
424
|
# ── connect() — controller side ───────────────────────────────────────────────
|
|
425
425
|
|
|
426
426
|
def connect_run(api_key: str, base_url: str, command: str,
|
|
427
|
-
target: Optional[str] = None
|
|
427
|
+
target: Optional[str] = None,
|
|
428
|
+
on_connected: Optional[Callable[[str], None]] = None) -> int:
|
|
428
429
|
"""
|
|
429
430
|
Run a command on a remote device via the relay.
|
|
430
431
|
Returns the remote exit code.
|
|
@@ -441,6 +442,8 @@ def connect_run(api_key: str, base_url: str, command: str,
|
|
|
441
442
|
if ack.get("type") == "error":
|
|
442
443
|
raise RuntimeError(ack.get("message", "relay error"))
|
|
443
444
|
device = ack.get("device", target or "?")
|
|
445
|
+
if on_connected:
|
|
446
|
+
on_connected(device)
|
|
444
447
|
|
|
445
448
|
# Send the run command
|
|
446
449
|
ws.send(json.dumps({"type": "run", "command": command}))
|
|
@@ -473,7 +476,8 @@ def connect_run(api_key: str, base_url: str, command: str,
|
|
|
473
476
|
|
|
474
477
|
|
|
475
478
|
def connect_shell(api_key: str, base_url: str,
|
|
476
|
-
target: Optional[str] = None
|
|
479
|
+
target: Optional[str] = None,
|
|
480
|
+
on_connected: Optional[Callable[[str], None]] = None) -> int:
|
|
477
481
|
"""
|
|
478
482
|
Open a full interactive PTY shell on a remote device via the relay.
|
|
479
483
|
Returns the remote exit code.
|
|
@@ -517,6 +521,8 @@ def connect_shell(api_key: str, base_url: str,
|
|
|
517
521
|
raise RuntimeError(ack.get("message", "relay error"))
|
|
518
522
|
|
|
519
523
|
device = ack.get("device", target or "?")
|
|
524
|
+
if on_connected:
|
|
525
|
+
on_connected(device)
|
|
520
526
|
# Tell device to open a PTY shell
|
|
521
527
|
ws.send(json.dumps({
|
|
522
528
|
"type": "pty_run",
|
|
@@ -160,39 +160,54 @@ class VaultUploader:
|
|
|
160
160
|
interval: float = 2.0
|
|
161
161
|
) -> UploadResult:
|
|
162
162
|
"""
|
|
163
|
-
Polls /api/status/{file_code}
|
|
163
|
+
Polls /api/status/{file_code} with animated server-stage feedback.
|
|
164
164
|
Blocks for at most `timeout` seconds.
|
|
165
165
|
"""
|
|
166
166
|
deadline = time.time() + timeout
|
|
167
167
|
url = f"{self.client.base_url}/api/status/{result.file_code}"
|
|
168
168
|
|
|
169
|
-
|
|
170
|
-
|
|
169
|
+
spin = None
|
|
170
|
+
if sys.stdout.isatty():
|
|
171
171
|
try:
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
d = resp.json()
|
|
176
|
-
status = d.get("status", "")
|
|
177
|
-
if status == "done":
|
|
178
|
-
# file_code may be updated in the task record
|
|
179
|
-
final_code = d.get("file_code") or result.file_code
|
|
180
|
-
return UploadResult(
|
|
181
|
-
status="done",
|
|
182
|
-
file_code=final_code,
|
|
183
|
-
embed_url=result.embed_url.replace(result.file_code, final_code),
|
|
184
|
-
direct_url=result.direct_url.replace(result.file_code, final_code),
|
|
185
|
-
input_type=result.input_type,
|
|
186
|
-
dna=result.dna
|
|
187
|
-
)
|
|
188
|
-
if status == "error":
|
|
189
|
-
raise UploadFailed(f"Server failed to vault file: {d.get('detail', 'unknown error')}")
|
|
190
|
-
except UploadFailed:
|
|
191
|
-
raise
|
|
172
|
+
from .cli import _Spin
|
|
173
|
+
spin = _Spin(f"vaulting {result.file_code}…")
|
|
174
|
+
spin.__enter__()
|
|
192
175
|
except Exception:
|
|
193
|
-
|
|
176
|
+
spin = None
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
while time.time() < deadline:
|
|
180
|
+
time.sleep(interval)
|
|
181
|
+
try:
|
|
182
|
+
resp = httpx.get(url, headers=self.client._get_headers(), timeout=10)
|
|
183
|
+
if resp.status_code == 404:
|
|
184
|
+
break
|
|
185
|
+
d = resp.json()
|
|
186
|
+
status = d.get("status", "")
|
|
187
|
+
detail = d.get("detail", "")
|
|
188
|
+
if spin and (status or detail):
|
|
189
|
+
stage_txt = detail or status
|
|
190
|
+
spin.update(f"vaulting {result.file_code} ({stage_txt})…")
|
|
191
|
+
if status == "done":
|
|
192
|
+
final_code = d.get("file_code") or result.file_code
|
|
193
|
+
return UploadResult(
|
|
194
|
+
status="done",
|
|
195
|
+
file_code=final_code,
|
|
196
|
+
embed_url=result.embed_url.replace(result.file_code, final_code),
|
|
197
|
+
direct_url=result.direct_url.replace(result.file_code, final_code),
|
|
198
|
+
input_type=result.input_type,
|
|
199
|
+
dna=result.dna
|
|
200
|
+
)
|
|
201
|
+
if status == "error":
|
|
202
|
+
raise UploadFailed(f"Server failed to vault file: {d.get('detail', 'unknown error')}")
|
|
203
|
+
except UploadFailed:
|
|
204
|
+
raise
|
|
205
|
+
except Exception:
|
|
206
|
+
pass
|
|
207
|
+
finally:
|
|
208
|
+
if spin:
|
|
209
|
+
spin.__exit__(None, None, None)
|
|
194
210
|
|
|
195
|
-
# Timeout: return what we have (caller can still use embed_url)
|
|
196
211
|
return result
|
|
197
212
|
|
|
198
213
|
def upload_directory(self, folder_path: str, max_workers: int = 3) -> List[UploadResult]:
|
|
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
|
|
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
|