unitport 0.0.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.
- unitport-0.0.1/PKG-INFO +79 -0
- unitport-0.0.1/README.md +60 -0
- unitport-0.0.1/pyproject.toml +45 -0
- unitport-0.0.1/setup.cfg +4 -0
- unitport-0.0.1/tests/test_bundle_security.py +71 -0
- unitport-0.0.1/tests/test_capabilities.py +35 -0
- unitport-0.0.1/tests/test_config.py +59 -0
- unitport-0.0.1/tests/test_e2e_run.py +83 -0
- unitport-0.0.1/tests/test_protocol.py +53 -0
- unitport-0.0.1/unitport/__init__.py +26 -0
- unitport-0.0.1/unitport/__main__.py +9 -0
- unitport-0.0.1/unitport/bundle.py +152 -0
- unitport-0.0.1/unitport/capabilities.py +196 -0
- unitport-0.0.1/unitport/cli.py +196 -0
- unitport-0.0.1/unitport/config.py +275 -0
- unitport-0.0.1/unitport/doctor.py +146 -0
- unitport-0.0.1/unitport/errors.py +55 -0
- unitport-0.0.1/unitport/pairing.py +156 -0
- unitport-0.0.1/unitport/protocol.py +151 -0
- unitport-0.0.1/unitport/results.py +111 -0
- unitport-0.0.1/unitport/runner.py +283 -0
- unitport-0.0.1/unitport/security.py +65 -0
- unitport-0.0.1/unitport/transport.py +151 -0
- unitport-0.0.1/unitport/verbs.py +127 -0
- unitport-0.0.1/unitport.egg-info/PKG-INFO +79 -0
- unitport-0.0.1/unitport.egg-info/SOURCES.txt +28 -0
- unitport-0.0.1/unitport.egg-info/dependency_links.txt +1 -0
- unitport-0.0.1/unitport.egg-info/entry_points.txt +2 -0
- unitport-0.0.1/unitport.egg-info/requires.txt +7 -0
- unitport-0.0.1/unitport.egg-info/top_level.txt +1 -0
unitport-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: unitport
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: UnitPort local helper — headless daemon bridging local Isaac Lab / MuJoCo / resources to the UnitPort cloud canvas.
|
|
5
|
+
Author: UnitPort
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Project-URL: Homepage, https://unitport.ai
|
|
8
|
+
Keywords: unitport,isaac-lab,mujoco,reinforcement-learning,robotics
|
|
9
|
+
Classifier: Environment :: No Input/Output (Daemon)
|
|
10
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
11
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Requires-Dist: websockets>=12
|
|
16
|
+
Requires-Dist: tomli>=2.0; python_version < "3.11"
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
19
|
+
|
|
20
|
+
# unitport — UnitPort local helper
|
|
21
|
+
|
|
22
|
+
Headless daemon that connects a local **GPU / Isaac Lab host** (workstation *or*
|
|
23
|
+
the user's own cloud server) to the UnitPort **cloud canvas**. The canvas is the
|
|
24
|
+
remote control (遥控器); this process has **no GUI** — only terminal output.
|
|
25
|
+
|
|
26
|
+
> Authoritative design: `../knowledges/local_helper_design.md`
|
|
27
|
+
> (rulings LH-D1…LH-D6, contracts **C10** pairing / **C11** control protocol).
|
|
28
|
+
|
|
29
|
+
## What it is (and is not)
|
|
30
|
+
|
|
31
|
+
- **Is:** an outbound-only WebSocket agent exposing a *fixed* verb set to the
|
|
32
|
+
cloud (report capabilities, publish an asset index, receive a bundle from the
|
|
33
|
+
user's own canvas, start/stop a training run, stream telemetry).
|
|
34
|
+
- **Is not:** a shell, an exec server, or a GUI. The cloud can never send an
|
|
35
|
+
arbitrary command — only the fixed verbs, only from a paired session of the
|
|
36
|
+
**same account**, only bundles that originate from the user's own canvas
|
|
37
|
+
(least-privilege red line, LH-D4 / C11).
|
|
38
|
+
|
|
39
|
+
The same binary serves execution target ① (`UserServerTarget`, user cloud
|
|
40
|
+
server) and ③ (`LocalHelperTarget`, local host) — only the host differs (LH-D5).
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
pip install unitport
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Cross-platform (Windows + Linux). Runtime deps: `websockets` (the live link) and,
|
|
49
|
+
on Python < 3.11, `tomli` (TOML reader). Everything else is stdlib.
|
|
50
|
+
|
|
51
|
+
## CLI (headless — everything else is driven remotely from the canvas)
|
|
52
|
+
|
|
53
|
+
| Command | Purpose |
|
|
54
|
+
|---|---|
|
|
55
|
+
| `unitport login` | Browser-approve device pairing; stores a refreshable helper token |
|
|
56
|
+
| `unitport up` (`serve`) | Dial out to the cloud relay and serve the fixed verb set |
|
|
57
|
+
| `unitport status` | Connection / run state, paired account, config summary |
|
|
58
|
+
| `unitport config` | Declare Isaac/MuJoCo interpreter paths + resource roots |
|
|
59
|
+
| `unitport doctor` | Fail-loud self-check (interpreters, deps, cloud, token) |
|
|
60
|
+
|
|
61
|
+
There are deliberately **no** `pull` / `train` / `push` local subcommands — those
|
|
62
|
+
are *remote* verbs, triggered from the canvas (C11 §4).
|
|
63
|
+
|
|
64
|
+
## Config
|
|
65
|
+
|
|
66
|
+
- Config dir: `%APPDATA%\unitport\` (Windows) · `$XDG_CONFIG_HOME/unitport/`
|
|
67
|
+
or `~/.config/unitport/` (POSIX). Override with `UNITPORT_HOME`.
|
|
68
|
+
- `config.toml` — cloud base URL, engine interpreter paths, `resource_roots`,
|
|
69
|
+
workspace dir. Human-editable; also managed by `unitport config`.
|
|
70
|
+
- `token.json` — the helper token (secret; `chmod 600` on POSIX). Never printed,
|
|
71
|
+
never logged.
|
|
72
|
+
- `device.json` — the persistent (non-secret) `device_id`.
|
|
73
|
+
|
|
74
|
+
## Status
|
|
75
|
+
|
|
76
|
+
Under active construction (phases LH-P1…LH-P8, Isaac Lab first). See the design
|
|
77
|
+
doc §12 for the phase map. This tree currently implements the local foundation
|
|
78
|
+
(config, capability detection, doctor, CLI) plus the pairing/transport clients;
|
|
79
|
+
the `workers/relay` Durable Object cloud side lives in `../workers/relay`.
|
unitport-0.0.1/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# unitport — UnitPort local helper
|
|
2
|
+
|
|
3
|
+
Headless daemon that connects a local **GPU / Isaac Lab host** (workstation *or*
|
|
4
|
+
the user's own cloud server) to the UnitPort **cloud canvas**. The canvas is the
|
|
5
|
+
remote control (遥控器); this process has **no GUI** — only terminal output.
|
|
6
|
+
|
|
7
|
+
> Authoritative design: `../knowledges/local_helper_design.md`
|
|
8
|
+
> (rulings LH-D1…LH-D6, contracts **C10** pairing / **C11** control protocol).
|
|
9
|
+
|
|
10
|
+
## What it is (and is not)
|
|
11
|
+
|
|
12
|
+
- **Is:** an outbound-only WebSocket agent exposing a *fixed* verb set to the
|
|
13
|
+
cloud (report capabilities, publish an asset index, receive a bundle from the
|
|
14
|
+
user's own canvas, start/stop a training run, stream telemetry).
|
|
15
|
+
- **Is not:** a shell, an exec server, or a GUI. The cloud can never send an
|
|
16
|
+
arbitrary command — only the fixed verbs, only from a paired session of the
|
|
17
|
+
**same account**, only bundles that originate from the user's own canvas
|
|
18
|
+
(least-privilege red line, LH-D4 / C11).
|
|
19
|
+
|
|
20
|
+
The same binary serves execution target ① (`UserServerTarget`, user cloud
|
|
21
|
+
server) and ③ (`LocalHelperTarget`, local host) — only the host differs (LH-D5).
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
pip install unitport
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Cross-platform (Windows + Linux). Runtime deps: `websockets` (the live link) and,
|
|
30
|
+
on Python < 3.11, `tomli` (TOML reader). Everything else is stdlib.
|
|
31
|
+
|
|
32
|
+
## CLI (headless — everything else is driven remotely from the canvas)
|
|
33
|
+
|
|
34
|
+
| Command | Purpose |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `unitport login` | Browser-approve device pairing; stores a refreshable helper token |
|
|
37
|
+
| `unitport up` (`serve`) | Dial out to the cloud relay and serve the fixed verb set |
|
|
38
|
+
| `unitport status` | Connection / run state, paired account, config summary |
|
|
39
|
+
| `unitport config` | Declare Isaac/MuJoCo interpreter paths + resource roots |
|
|
40
|
+
| `unitport doctor` | Fail-loud self-check (interpreters, deps, cloud, token) |
|
|
41
|
+
|
|
42
|
+
There are deliberately **no** `pull` / `train` / `push` local subcommands — those
|
|
43
|
+
are *remote* verbs, triggered from the canvas (C11 §4).
|
|
44
|
+
|
|
45
|
+
## Config
|
|
46
|
+
|
|
47
|
+
- Config dir: `%APPDATA%\unitport\` (Windows) · `$XDG_CONFIG_HOME/unitport/`
|
|
48
|
+
or `~/.config/unitport/` (POSIX). Override with `UNITPORT_HOME`.
|
|
49
|
+
- `config.toml` — cloud base URL, engine interpreter paths, `resource_roots`,
|
|
50
|
+
workspace dir. Human-editable; also managed by `unitport config`.
|
|
51
|
+
- `token.json` — the helper token (secret; `chmod 600` on POSIX). Never printed,
|
|
52
|
+
never logged.
|
|
53
|
+
- `device.json` — the persistent (non-secret) `device_id`.
|
|
54
|
+
|
|
55
|
+
## Status
|
|
56
|
+
|
|
57
|
+
Under active construction (phases LH-P1…LH-P8, Isaac Lab first). See the design
|
|
58
|
+
doc §12 for the phase map. This tree currently implements the local foundation
|
|
59
|
+
(config, capability detection, doctor, CLI) plus the pairing/transport clients;
|
|
60
|
+
the `workers/relay` Durable Object cloud side lives in `../workers/relay`.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "unitport"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "UnitPort local helper — headless daemon bridging local Isaac Lab / MuJoCo / resources to the UnitPort cloud canvas."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "Proprietary" }
|
|
12
|
+
authors = [{ name = "UnitPort" }]
|
|
13
|
+
keywords = ["unitport", "isaac-lab", "mujoco", "reinforcement-learning", "robotics"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Environment :: No Input/Output (Daemon)",
|
|
16
|
+
"Operating System :: Microsoft :: Windows",
|
|
17
|
+
"Operating System :: POSIX :: Linux",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
# Runtime deps kept minimal on purpose (LH-D1 "lightweight"):
|
|
22
|
+
# * websockets — the outbound live link (LH-D2); lazily imported so config/
|
|
23
|
+
# doctor/capabilities work without it installed.
|
|
24
|
+
# * tomli — TOML *reader* backport for Python < 3.11 (stdlib tomllib on
|
|
25
|
+
# 3.11+). We hand-roll the writer, so no writer dep.
|
|
26
|
+
# Everything else (pairing HTTP, atomic IO, capability probes) is pure stdlib.
|
|
27
|
+
dependencies = [
|
|
28
|
+
"websockets>=12",
|
|
29
|
+
"tomli>=2.0 ; python_version < '3.11'",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = ["pytest>=7"]
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
unitport = "unitport.cli:main"
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://unitport.ai"
|
|
40
|
+
|
|
41
|
+
[tool.setuptools]
|
|
42
|
+
packages = ["unitport"]
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
testpaths = ["tests"]
|
unitport-0.0.1/setup.cfg
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Least-privilege boundary tests (C11 §7): own-canvas provenance + helper://
|
|
2
|
+
path-escape guard. These must fail LOUD, never degrade."""
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from unitport import bundle as bundlemod
|
|
9
|
+
from unitport import config as cfgmod
|
|
10
|
+
from unitport import security
|
|
11
|
+
from unitport.errors import SecurityError, VerbError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture
|
|
15
|
+
def home(tmp_path, monkeypatch):
|
|
16
|
+
monkeypatch.setenv("UNITPORT_HOME", str(tmp_path))
|
|
17
|
+
return tmp_path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _bundle(uid):
|
|
21
|
+
return {
|
|
22
|
+
"run_id": "r1",
|
|
23
|
+
"backend": "sb3_mujoco",
|
|
24
|
+
"provenance": {"owner_uid": uid},
|
|
25
|
+
"env_cfg": {"source": "# cfg\n"},
|
|
26
|
+
"spec": {},
|
|
27
|
+
"assets": [],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_foreign_bundle_rejected(home):
|
|
32
|
+
cfgmod.set_workspace(str(home / "ws"))
|
|
33
|
+
with pytest.raises(SecurityError):
|
|
34
|
+
bundlemod.materialize(_bundle("someone-else"), paired_uid="me", cfg=cfgmod.load_config())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_missing_owner_rejected(home):
|
|
38
|
+
b = _bundle("")
|
|
39
|
+
with pytest.raises(SecurityError):
|
|
40
|
+
bundlemod.materialize(b, paired_uid="me", cfg=cfgmod.load_config())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_helper_uri_inside_root_ok(home):
|
|
44
|
+
root = home / "assets"
|
|
45
|
+
root.mkdir()
|
|
46
|
+
f = root / "go2.xml"
|
|
47
|
+
f.write_text("<mujoco/>", encoding="utf-8")
|
|
48
|
+
got = security.resolve_helper_uri(f"helper://{f.as_posix()}", [str(root)])
|
|
49
|
+
assert got == f.resolve()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_helper_uri_escape_rejected(home):
|
|
53
|
+
root = home / "assets"
|
|
54
|
+
root.mkdir()
|
|
55
|
+
outside = home / "secret.txt"
|
|
56
|
+
outside.write_text("nope", encoding="utf-8")
|
|
57
|
+
with pytest.raises(SecurityError):
|
|
58
|
+
security.resolve_helper_uri(f"helper://{outside.as_posix()}", [str(root)])
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_helper_uri_no_roots_rejected(home):
|
|
62
|
+
with pytest.raises(SecurityError):
|
|
63
|
+
security.resolve_helper_uri("helper:///anything", [])
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_unknown_backend_rejected(home):
|
|
67
|
+
cfgmod.set_workspace(str(home / "ws"))
|
|
68
|
+
b = _bundle("me")
|
|
69
|
+
b["backend"] = "tensorflow_magic"
|
|
70
|
+
with pytest.raises(VerbError):
|
|
71
|
+
bundlemod.materialize(b, paired_uid="me", cfg=cfgmod.load_config())
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from unitport import CAPABILITIES_SCHEMA
|
|
4
|
+
from unitport import capabilities as caps
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@pytest.fixture
|
|
8
|
+
def home(tmp_path, monkeypatch):
|
|
9
|
+
monkeypatch.setenv("UNITPORT_HOME", str(tmp_path))
|
|
10
|
+
return tmp_path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_manifest_shape(home):
|
|
14
|
+
m = caps.build_manifest()
|
|
15
|
+
assert m["schema"] == CAPABILITIES_SCHEMA
|
|
16
|
+
assert m["host"]["os"] in ("windows", "linux", "darwin", "unknown")
|
|
17
|
+
assert set(m["engines"]) == {"isaac_lab", "mujoco", "sb3"}
|
|
18
|
+
for eng in m["engines"].values():
|
|
19
|
+
assert "installed" in eng
|
|
20
|
+
assert isinstance(m["gpu"]["nvidia_smi"], list)
|
|
21
|
+
assert isinstance(m["conda_envs"], list)
|
|
22
|
+
assert m["reported_at"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_isaac_absent_when_unconfigured(home):
|
|
26
|
+
# No interpreter declared → not installed, never raises.
|
|
27
|
+
got = caps.detect_isaac_lab({"installs": {}})
|
|
28
|
+
assert got["installed"] is False
|
|
29
|
+
assert got["python"] is None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_isaac_missing_path(home):
|
|
33
|
+
got = caps.detect_isaac_lab({"installs": {"isaac_lab": {"python": "/no/such/python"}}})
|
|
34
|
+
assert got["installed"] is False
|
|
35
|
+
assert got["python_exists"] is False
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import stat
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from unitport import config as cfgmod
|
|
7
|
+
from unitport.errors import ConfigError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture
|
|
11
|
+
def home(tmp_path, monkeypatch):
|
|
12
|
+
monkeypatch.setenv("UNITPORT_HOME", str(tmp_path))
|
|
13
|
+
monkeypatch.delenv("UNITPORT_BASE_URL", raising=False)
|
|
14
|
+
return tmp_path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_config_roundtrip_nested_tables(home):
|
|
18
|
+
cfg = cfgmod.set_engine_python("isaac_lab", r"C:\isaac\python.exe")
|
|
19
|
+
assert cfg["installs"]["isaac_lab"]["python"] == "C:/isaac/python.exe"
|
|
20
|
+
# Reloading parses the TOML we wrote back into the same structure.
|
|
21
|
+
reloaded = cfgmod.load_config()
|
|
22
|
+
assert reloaded["installs"]["isaac_lab"]["python"] == "C:/isaac/python.exe"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_add_resource_root_dedupes(home):
|
|
26
|
+
cfgmod.add_resource_root(r"A:\robots")
|
|
27
|
+
cfg = cfgmod.add_resource_root("A:/robots") # same, forward-slash form
|
|
28
|
+
assert cfg["resource"]["roots"] == ["A:/robots"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_base_url_precedence(home, monkeypatch):
|
|
32
|
+
cfgmod.set_base_url("https://config.example")
|
|
33
|
+
assert cfgmod.base_url() == "https://config.example"
|
|
34
|
+
monkeypatch.setenv("UNITPORT_BASE_URL", "https://env.example/")
|
|
35
|
+
assert cfgmod.base_url() == "https://env.example"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_device_id_stable(home):
|
|
39
|
+
a = cfgmod.get_or_create_device_id()
|
|
40
|
+
b = cfgmod.get_or_create_device_id()
|
|
41
|
+
assert a == b and len(a) == 32
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_token_roundtrip_and_perms(home):
|
|
45
|
+
cfgmod.save_token({"access_token": "secret", "uid": "u1", "device_id": "d1"})
|
|
46
|
+
tok = cfgmod.load_token()
|
|
47
|
+
assert tok["access_token"] == "secret"
|
|
48
|
+
if os.name != "nt": # POSIX: token must be 0600
|
|
49
|
+
mode = stat.S_IMODE(os.stat(cfgmod.token_path()).st_mode)
|
|
50
|
+
assert mode == 0o600
|
|
51
|
+
cfgmod.clear_token()
|
|
52
|
+
assert cfgmod.load_token() is None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_malformed_toml_fails_loud(home):
|
|
56
|
+
cfgmod.config_path().parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
cfgmod.config_path().write_text("this is = = not toml", encoding="utf-8")
|
|
58
|
+
with pytest.raises(ConfigError):
|
|
59
|
+
cfgmod.load_config()
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""In-process end-to-end: receive-bundle → materialize → start-run → telemetry →
|
|
2
|
+
push-results, driven against the mock trainer (no relay, no GPU). Proves the
|
|
3
|
+
LH-P4/P5/P6 plumbing deterministically."""
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from unitport import bundle as bundlemod
|
|
11
|
+
from unitport import config as cfgmod
|
|
12
|
+
from unitport.verbs import HelperContext
|
|
13
|
+
|
|
14
|
+
MOCK = str(Path(__file__).parent / "mock_trainer.py")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def home(tmp_path, monkeypatch):
|
|
19
|
+
monkeypatch.setenv("UNITPORT_HOME", str(tmp_path))
|
|
20
|
+
return tmp_path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _make_bundle(uid, source, run_id="smoke-1"):
|
|
24
|
+
return {
|
|
25
|
+
"run_id": run_id,
|
|
26
|
+
"backend": "sb3_mujoco",
|
|
27
|
+
"provenance": {"owner_uid": uid},
|
|
28
|
+
"env_cfg": {"file_name": "unitport_env_cfg.py", "source": source},
|
|
29
|
+
"spec": {"max_iterations": 3},
|
|
30
|
+
"assets": [],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_receive_start_results(home):
|
|
35
|
+
cfgmod.save_token({"access_token": "x", "uid": "uid-test", "device_id": "d1"})
|
|
36
|
+
cfgmod.set_engine_field("sb3_mujoco", "launcher", MOCK)
|
|
37
|
+
cfgmod.set_engine_field("sb3_mujoco", "python", sys.executable)
|
|
38
|
+
cfgmod.set_workspace(str(home / "ws"))
|
|
39
|
+
|
|
40
|
+
ctx = HelperContext()
|
|
41
|
+
events = []
|
|
42
|
+
ctx.emit = events.append
|
|
43
|
+
assert ctx.paired_uid == "uid-test"
|
|
44
|
+
|
|
45
|
+
# 1) receive-bundle → materialize
|
|
46
|
+
run = bundlemod.materialize(_make_bundle("uid-test", "# env cfg\n"), ctx.paired_uid, ctx.cfg)
|
|
47
|
+
assert (run.run_dir / "unitport_env_cfg.py").read_text(encoding="utf-8") == "# env cfg\n"
|
|
48
|
+
assert (run.run_dir / "spec.json").exists()
|
|
49
|
+
assert (run.run_dir / "run.json").exists()
|
|
50
|
+
ctx.runs[run.run_id] = {"run": run}
|
|
51
|
+
|
|
52
|
+
# 2) start-run → real subprocess (mock trainer) → 3) telemetry
|
|
53
|
+
runner = ctx.get_runner()
|
|
54
|
+
runner.start(run)
|
|
55
|
+
runner.wait(run.run_id, timeout=30)
|
|
56
|
+
|
|
57
|
+
tail = [e["payload"] for e in events if e.get("verb") == "tail-logs"]
|
|
58
|
+
kinds = {p.get("kind") for p in tail}
|
|
59
|
+
assert "progress" in kinds
|
|
60
|
+
assert "cmdmetrics" in kinds
|
|
61
|
+
assert "done_sentinel" in kinds
|
|
62
|
+
# a progress frame carries iteration/total
|
|
63
|
+
prog = [p for p in tail if p.get("kind") == "progress"]
|
|
64
|
+
assert prog and prog[-1]["iteration"] == 3 and prog[-1]["total"] == 3
|
|
65
|
+
|
|
66
|
+
# 4) push-results emitted with a checkpoint manifest
|
|
67
|
+
pushes = [e for e in events if e.get("verb") == "push-results"]
|
|
68
|
+
assert pushes, "no push-results emitted"
|
|
69
|
+
payload = pushes[0]["payload"]
|
|
70
|
+
assert Path(payload["checkpoint"]).exists()
|
|
71
|
+
man = payload["manifest"]
|
|
72
|
+
assert man["schema"] == "unitport.resource/bundles@1"
|
|
73
|
+
assert man["payload"]["bundle_kind"] == "checkpoint"
|
|
74
|
+
assert man["payload"]["checkpoint_ref"].startswith("helper://")
|
|
75
|
+
assert man["payload"]["run_id"] == "smoke-1"
|
|
76
|
+
assert len(man["id"]) == 12 # 12-hex SKU
|
|
77
|
+
|
|
78
|
+
# run.json reflects completion
|
|
79
|
+
import json
|
|
80
|
+
|
|
81
|
+
rj = json.loads((run.run_dir / "run.json").read_text(encoding="utf-8"))
|
|
82
|
+
assert rj["status"] == "completed"
|
|
83
|
+
assert rj["returncode"] == 0
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from unitport import protocol
|
|
4
|
+
from unitport.errors import ProtocolError, SecurityError
|
|
5
|
+
from unitport.protocol import Verb
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_envelope_roundtrip():
|
|
9
|
+
env = protocol.make_req(Verb.RECEIVE_BUNDLE, id="1", payload={"a": 1}, run_id="r1")
|
|
10
|
+
raw = protocol.dumps(env)
|
|
11
|
+
back = protocol.parse_envelope(raw)
|
|
12
|
+
assert back["verb"] == Verb.RECEIVE_BUNDLE
|
|
13
|
+
assert back["run_id"] == "r1"
|
|
14
|
+
assert back["kind"] == protocol.KIND_REQ
|
|
15
|
+
assert back["v"] == protocol.PROTOCOL_VERSION
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_parse_rejects_garbage():
|
|
19
|
+
with pytest.raises(ProtocolError):
|
|
20
|
+
protocol.parse_envelope("not json")
|
|
21
|
+
with pytest.raises(ProtocolError):
|
|
22
|
+
protocol.parse_envelope("[]") # not an object
|
|
23
|
+
with pytest.raises(ProtocolError):
|
|
24
|
+
protocol.parse_envelope('{"v":999,"kind":"req"}') # bad version
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_gate_allows_cloud_to_helper_verb():
|
|
28
|
+
env = protocol.make_req(Verb.RECEIVE_BUNDLE, id="1")
|
|
29
|
+
assert protocol.assert_inbound_allowed(env) == Verb.RECEIVE_BUNDLE
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_gate_rejects_unknown_verb():
|
|
33
|
+
env = protocol.make_req("rm -rf /", id="1")
|
|
34
|
+
with pytest.raises(SecurityError):
|
|
35
|
+
protocol.assert_inbound_allowed(env)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_gate_rejects_wrong_direction_verb():
|
|
39
|
+
# The cloud must not be able to *invoke* a helper→cloud-only verb.
|
|
40
|
+
env = protocol.make_req(Verb.REPORT_CAPABILITIES, id="1")
|
|
41
|
+
with pytest.raises(SecurityError):
|
|
42
|
+
protocol.assert_inbound_allowed(env)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_gate_lets_responses_through():
|
|
46
|
+
env = protocol.make_res(id="1", payload={"ok": True})
|
|
47
|
+
assert protocol.assert_inbound_allowed(env) == ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_gate_rejects_missing_verb_on_req():
|
|
51
|
+
env = protocol.make_envelope(protocol.KIND_REQ, id="1")
|
|
52
|
+
with pytest.raises(SecurityError):
|
|
53
|
+
protocol.assert_inbound_allowed(env)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""unitport — UnitPort local helper (headless daemon).
|
|
3
|
+
|
|
4
|
+
Package-level constants shared across modules. Keep this import cheap: NO heavy
|
|
5
|
+
third-party imports here (websockets etc. are imported lazily where used) so that
|
|
6
|
+
``config`` / ``doctor`` / ``capabilities`` stay usable even in a broken env.
|
|
7
|
+
|
|
8
|
+
Authoritative design: ``knowledges/local_helper_design.md`` (LH-D1…D6, C10/C11).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "0.0.1"
|
|
12
|
+
|
|
13
|
+
# Wire / schema identifiers — must match the design doc and the cloud edge.
|
|
14
|
+
CAPABILITIES_SCHEMA = "unitport.helper.capabilities@1" # design §5
|
|
15
|
+
PROTOCOL_VERSION = 1 # envelope { v, kind, id, verb, run_id?, payload, ts } §2
|
|
16
|
+
|
|
17
|
+
# Cloud edge (Cloudflare Workers, design §11). Overridable via config.toml
|
|
18
|
+
# [cloud].base_url or the UNITPORT_BASE_URL env var — never hard-required here.
|
|
19
|
+
DEFAULT_BASE_URL = "https://svc.unitport.ai"
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"__version__",
|
|
23
|
+
"CAPABILITIES_SCHEMA",
|
|
24
|
+
"PROTOCOL_VERSION",
|
|
25
|
+
"DEFAULT_BASE_URL",
|
|
26
|
+
]
|