wizelit-client 0.1.18__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.
@@ -0,0 +1,47 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.egg-info/
6
+ dist/
7
+ build/
8
+ .cli_bundle/
9
+ .agent/
10
+ .venv/
11
+ *.egg
12
+
13
+ # Node
14
+ node_modules/
15
+ dist/
16
+
17
+ # Environment
18
+ .env
19
+ !.env.template
20
+
21
+ # IDE
22
+ .vscode/
23
+ .idea/
24
+ *.swp
25
+ *.swo
26
+
27
+ # OS
28
+ .DS_Store
29
+ Thumbs.db
30
+
31
+ # Data
32
+ data/
33
+
34
+ # Test
35
+ .pytest_cache/
36
+ .coverage
37
+ htmlcov/
38
+ videos/
39
+
40
+ # Build
41
+ *.whl
42
+
43
+ # AWS CDK
44
+ cdk.out/
45
+ cdk.context.json
46
+
47
+ local_*
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.4
2
+ Name: wizelit-client
3
+ Version: 0.1.18
4
+ Summary: Wizelit client auth — device-link pairing and credential storage for CLI and ACP
5
+ Author-email: Wizeline <engineering@wizeline.com>
6
+ License-Expression: MIT
7
+ Keywords: acp,auth,cli,wizelit
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Requires-Python: >=3.12
16
+ Requires-Dist: httpx>=0.27.0
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
19
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # wizelit-client
23
+
24
+ Shared device-link authentication and credential storage for Wizelit CLI and ACP clients.
25
+
26
+ Credentials are stored in `~/.wizelit/credentials.json` after browser pairing.
@@ -0,0 +1,5 @@
1
+ # wizelit-client
2
+
3
+ Shared device-link authentication and credential storage for Wizelit CLI and ACP clients.
4
+
5
+ Credentials are stored in `~/.wizelit/credentials.json` after browser pairing.
@@ -0,0 +1,41 @@
1
+ [project]
2
+ name = "wizelit-client"
3
+ version = "0.1.18"
4
+ description = "Wizelit client auth — device-link pairing and credential storage for CLI and ACP"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.12"
8
+ authors = [{ name = "Wizeline", email = "engineering@wizeline.com" }]
9
+ keywords = ["wizelit", "auth", "cli", "acp"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Environment :: Console",
13
+ "Intended Audience :: Developers",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ ]
19
+ dependencies = [
20
+ "httpx>=0.27.0",
21
+ ]
22
+
23
+ [build-system]
24
+ requires = ["hatchling"]
25
+ build-backend = "hatchling.build"
26
+
27
+ [tool.hatch.build.targets.wheel]
28
+ packages = ["wizelit_client"]
29
+
30
+ [tool.hatch.build.targets.sdist]
31
+ include = ["LICENSE", "README.md", "wizelit_client/**/*.py"]
32
+
33
+ [tool.pytest.ini_options]
34
+ asyncio_mode = "auto"
35
+ testpaths = ["tests"]
36
+
37
+ [project.optional-dependencies]
38
+ dev = [
39
+ "pytest>=8.0.0",
40
+ "pytest-asyncio>=0.24.0",
41
+ ]
@@ -0,0 +1,5 @@
1
+ """Shared client auth for Wizelit CLI and ACP."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("wizelit-client")
@@ -0,0 +1,54 @@
1
+ """Shared hub authentication for Wizelit CLI and ACP clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Awaitable, Callable
7
+
8
+ from wizelit_client.credentials import delete_credentials, load_credentials
9
+ from wizelit_client.device_login import pair_device
10
+ from wizelit_client.user_config import resolve_hub_api_url
11
+
12
+ StatusCallback = Callable[[str], Awaitable[None] | None]
13
+
14
+
15
+ def resolve_credentials() -> tuple[str, str] | None:
16
+ """Return saved ``(token, api_base)`` when they match the resolved hub URL."""
17
+ creds = load_credentials()
18
+ if not creds:
19
+ return None
20
+ if creds["api_base"].rstrip("/") != resolve_hub_api_url().rstrip("/"):
21
+ return None
22
+ return creds["token"], creds["api_base"]
23
+
24
+
25
+ async def _emit_status(on_status: StatusCallback | None, msg: str) -> None:
26
+ if on_status is None:
27
+ return
28
+ result = on_status(msg)
29
+ if asyncio.iscoroutine(result):
30
+ await result
31
+
32
+
33
+ async def ensure_authenticated(
34
+ *,
35
+ client_name: str,
36
+ on_status: StatusCallback | None = None,
37
+ force_pair: bool = False,
38
+ ) -> tuple[str, str] | None:
39
+ """Load saved credentials or run device-link pairing."""
40
+ if not force_pair:
41
+ resolved = resolve_credentials()
42
+ if resolved is not None:
43
+ return resolved
44
+
45
+ if force_pair:
46
+ delete_credentials()
47
+ await _emit_status(
48
+ on_status,
49
+ "Hub credentials were rejected. Starting a new device-link pairing…",
50
+ )
51
+ else:
52
+ await _emit_status(on_status, "No hub credentials found. Starting device-link pairing…")
53
+
54
+ return await pair_device(client_name=client_name, on_status=on_status)
@@ -0,0 +1,67 @@
1
+ """Read/write hub credentials for Wizelit CLI and ACP clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import TypedDict
9
+
10
+
11
+ class WizelitCredentials(TypedDict):
12
+ token: str
13
+ api_base: str
14
+
15
+
16
+ def credentials_path() -> Path:
17
+ override = os.environ.get("WIZELIT_CREDENTIALS_FILE")
18
+ if override:
19
+ return Path(override).expanduser()
20
+ return Path.home() / ".wizelit" / "credentials.json"
21
+
22
+
23
+ def load_credentials() -> WizelitCredentials | None:
24
+ path = credentials_path()
25
+ try:
26
+ raw = path.read_text(encoding="utf-8")
27
+ data = json.loads(raw)
28
+ token = data.get("token")
29
+ api_base = data.get("api_base")
30
+ if isinstance(token, str) and token.startswith("wiz_") and isinstance(api_base, str):
31
+ return {"token": token, "api_base": api_base.rstrip("/")}
32
+ except (OSError, json.JSONDecodeError, TypeError):
33
+ return None
34
+ return None
35
+
36
+
37
+ def _restrict_credentials_dir(path: Path) -> None:
38
+ """Tighten ``~/.wizelit`` so only the owner can traverse it."""
39
+ if os.environ.get("WIZELIT_CREDENTIALS_FILE") is not None:
40
+ return
41
+ try:
42
+ os.chmod(path, 0o700)
43
+ except OSError:
44
+ pass
45
+
46
+
47
+ def save_credentials(token: str, api_base: str) -> Path:
48
+ path = credentials_path()
49
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
50
+ _restrict_credentials_dir(path.parent)
51
+ payload = {"token": token, "api_base": api_base.rstrip("/")}
52
+ data = json.dumps(payload, indent=2) + "\n"
53
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
54
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
55
+ handle.write(data)
56
+ return path
57
+
58
+
59
+ def delete_credentials() -> bool:
60
+ path = credentials_path()
61
+ try:
62
+ path.unlink()
63
+ return True
64
+ except FileNotFoundError:
65
+ return False
66
+ except OSError:
67
+ return False
@@ -0,0 +1,142 @@
1
+ """Device-link login for Wizelit CLI and ACP clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ import sys
9
+ import time
10
+ import webbrowser
11
+ from collections.abc import Awaitable, Callable
12
+ from typing import Any
13
+
14
+ import httpx
15
+
16
+ from wizelit_client.credentials import delete_credentials, load_credentials, save_credentials
17
+ from wizelit_client.transport_security import require_secure_transport
18
+ from wizelit_client.user_config import resolve_hub_api_url
19
+
20
+ StatusCallback = Callable[[str], Awaitable[None] | None]
21
+
22
+
23
+ def api_url() -> str:
24
+ return resolve_hub_api_url()
25
+
26
+
27
+ def _log(msg: str) -> None:
28
+ print(f"wizelit: {msg}", file=sys.stderr, flush=True)
29
+
30
+
31
+ async def _notify(on_status: StatusCallback | None, msg: str) -> None:
32
+ _log(msg)
33
+ if on_status is None:
34
+ return
35
+ result = on_status(msg)
36
+ if asyncio.iscoroutine(result):
37
+ await result
38
+
39
+
40
+ async def pair_device(
41
+ *,
42
+ client_name: str | None = None,
43
+ on_status: StatusCallback | None = None,
44
+ open_browser: bool = True,
45
+ ) -> tuple[str, str] | None:
46
+ """Start device-link pairing and return ``(token, api_base)`` when approved."""
47
+ api_base = api_url()
48
+ try:
49
+ require_secure_transport(api_base)
50
+ except ValueError as exc:
51
+ await _notify(on_status, str(exc))
52
+ return None
53
+
54
+ payload: dict[str, str] = {}
55
+ if client_name:
56
+ payload["clientName"] = client_name
57
+
58
+ async with httpx.AsyncClient(timeout=30.0) as client:
59
+ start_resp = await client.post(f"{api_base}/api/auth/device", json=payload or None)
60
+ if start_resp.status_code != 200:
61
+ await _notify(
62
+ on_status,
63
+ f"Device pairing failed to start ({start_resp.status_code}). "
64
+ "Is the Wizelit backend running?",
65
+ )
66
+ return None
67
+
68
+ start = start_resp.json()
69
+ device_code = start["deviceCode"]
70
+ verification_uri = start.get("verificationUriComplete") or start["verificationUri"]
71
+ interval = max(1, int(start.get("interval", 5)))
72
+ expires_in = int(start.get("expiresIn", 600))
73
+
74
+ await _notify(
75
+ on_status,
76
+ f"Approve pairing in your browser: {verification_uri} (expires in {expires_in}s)",
77
+ )
78
+ if open_browser:
79
+ try:
80
+ webbrowser.open(verification_uri)
81
+ except Exception:
82
+ await _notify(
83
+ on_status,
84
+ "Could not open a browser automatically — open the URL above manually.",
85
+ )
86
+
87
+ deadline = time.monotonic() + expires_in
88
+ while time.monotonic() < deadline:
89
+ await asyncio.sleep(interval)
90
+ poll_resp = await client.post(
91
+ f"{api_base}/api/auth/device/token",
92
+ json={"deviceCode": device_code},
93
+ )
94
+ if poll_resp.status_code == 200:
95
+ token = poll_resp.json()["token"]
96
+ cred_path = save_credentials(token, api_base)
97
+ await _notify(on_status, f"Pairing complete. Credentials saved to {cred_path}")
98
+ return token, api_base
99
+
100
+ try:
101
+ detail: Any = poll_resp.json().get("detail")
102
+ except json.JSONDecodeError:
103
+ detail = poll_resp.text
104
+ if isinstance(detail, dict):
105
+ err = detail.get("error")
106
+ if err == "authorization_pending":
107
+ continue
108
+ if err == "slow_down":
109
+ retry = float(detail.get("retryAfter", interval))
110
+ interval = min(interval + 5, 60)
111
+ await asyncio.sleep(max(retry, 0.1))
112
+ continue
113
+ await _notify(on_status, f"Device poll failed ({poll_resp.status_code}): {detail}")
114
+ return None
115
+
116
+ await _notify(on_status, "Timed out waiting for browser authorization.")
117
+ return None
118
+
119
+
120
+ async def run_device_login(*, client_name: str | None = None) -> int:
121
+ paired = await pair_device(client_name=client_name)
122
+ return 0 if paired is not None else 1
123
+
124
+
125
+ async def run_device_logout() -> int:
126
+ creds = load_credentials()
127
+ if creds:
128
+ try:
129
+ require_secure_transport(creds["api_base"])
130
+ async with httpx.AsyncClient(timeout=10.0) as client:
131
+ await client.post(
132
+ f"{creds['api_base']}/api/auth/ide-tokens/revoke-current",
133
+ headers={"Authorization": f"Bearer {creds['token']}"},
134
+ )
135
+ except Exception as exc:
136
+ _log(f"server revoke skipped: {exc}")
137
+
138
+ if delete_credentials():
139
+ _log("removed saved credentials")
140
+ else:
141
+ _log("no credentials file found")
142
+ return 0
@@ -0,0 +1,74 @@
1
+ """Transport security checks before sending hub bearer tokens."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ import os
7
+ from urllib.parse import urlparse
8
+
9
+
10
+ def allow_insecure_transport() -> bool:
11
+ """Opt-in for sending credentials over plain HTTP/WS to remote hosts."""
12
+ return os.environ.get("WIZELIT_ALLOW_INSECURE", "").strip().lower() in {
13
+ "1",
14
+ "true",
15
+ "yes",
16
+ "on",
17
+ }
18
+
19
+
20
+ def is_loopback_host(host: str | None) -> bool:
21
+ if not host:
22
+ return False
23
+ normalized = host.strip("[]").lower()
24
+ if normalized == "localhost":
25
+ return True
26
+ try:
27
+ return ipaddress.ip_address(normalized).is_loopback
28
+ except ValueError:
29
+ return False
30
+
31
+
32
+ def is_insecure_remote_plaintext(url: str) -> bool:
33
+ """True when ``url`` uses plain HTTP or WS to a non-loopback host."""
34
+ parsed = urlparse(url)
35
+ if parsed.scheme not in {"http", "ws"}:
36
+ return False
37
+ return not is_loopback_host(parsed.hostname)
38
+
39
+
40
+ def require_secure_transport(url: str) -> None:
41
+ """Refuse cleartext hub token transport to remote hosts unless opted in."""
42
+ if not is_insecure_remote_plaintext(url):
43
+ return
44
+ if allow_insecure_transport():
45
+ return
46
+ secure_scheme = "wss://" if url.startswith("ws://") else "https://"
47
+ raise ValueError(
48
+ f"Refusing to send hub token over plaintext to non-loopback host ({url}). "
49
+ f"Use {secure_scheme} or set WIZELIT_ALLOW_INSECURE=1 to override (not recommended)."
50
+ )
51
+
52
+
53
+ def is_insecure_remote_http(base_url: str) -> bool:
54
+ """True when ``base_url`` is plain HTTP to a non-loopback host."""
55
+ parsed = urlparse(base_url)
56
+ if parsed.scheme != "http":
57
+ return False
58
+ return not is_loopback_host(parsed.hostname)
59
+
60
+
61
+ def bearer_auth_headers(base_url: str, token: str | None) -> dict[str, str]:
62
+ """Build Authorization headers, refusing cleartext token to remote hosts."""
63
+ if not token:
64
+ return {}
65
+ if is_insecure_remote_http(base_url):
66
+ if allow_insecure_transport():
67
+ pass
68
+ else:
69
+ raise ValueError(
70
+ f"Refusing to send hub token over plain HTTP to non-loopback host "
71
+ f"({base_url}). Use https:// or set WIZELIT_ALLOW_INSECURE=1 to "
72
+ "override (not recommended)."
73
+ )
74
+ return {"Authorization": f"Bearer {token}"}
@@ -0,0 +1,84 @@
1
+ """User-local Wizelit client configuration (``~/.wizelit/config.json``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from wizelit_client.credentials import credentials_path, load_credentials
11
+
12
+ _CONFIG_VERSION = 1
13
+ _DEFAULT_API_URL = "http://localhost:8000"
14
+
15
+
16
+ def config_dir() -> Path:
17
+ return credentials_path().parent
18
+
19
+
20
+ def config_path() -> Path:
21
+ override = os.environ.get("WIZELIT_CONFIG_FILE")
22
+ if override:
23
+ return Path(override).expanduser()
24
+ return config_dir() / "config.json"
25
+
26
+
27
+ def load_config() -> dict[str, Any]:
28
+ path = config_path()
29
+ try:
30
+ raw = path.read_text(encoding="utf-8")
31
+ data = json.loads(raw)
32
+ return data if isinstance(data, dict) else {}
33
+ except (OSError, json.JSONDecodeError):
34
+ return {}
35
+
36
+
37
+ def save_config(data: dict[str, Any]) -> Path:
38
+ path = config_path()
39
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
40
+ try:
41
+ os.chmod(path.parent, 0o700)
42
+ except OSError:
43
+ pass
44
+ payload = {"version": _CONFIG_VERSION, **data}
45
+ text = json.dumps(payload, indent=2) + "\n"
46
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
47
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
48
+ handle.write(text)
49
+ return path
50
+
51
+
52
+ def resolve_hub_api_url() -> str:
53
+ """Hub REST base URL: env → config.json → credentials.json → default."""
54
+ if raw := os.environ.get("WIZELIT_API_URL"):
55
+ return raw.rstrip("/")
56
+
57
+ config = load_config()
58
+ api_url = config.get("api_url")
59
+ if isinstance(api_url, str) and api_url.strip():
60
+ return api_url.strip().rstrip("/")
61
+
62
+ creds = load_credentials()
63
+ if creds:
64
+ return creds["api_base"].rstrip("/")
65
+
66
+ return _DEFAULT_API_URL
67
+
68
+
69
+ def apply_config_to_environ() -> None:
70
+ """Load ``config.json`` values into the process env (existing env wins)."""
71
+ config = load_config()
72
+
73
+ api_url = config.get("api_url")
74
+ if isinstance(api_url, str) and api_url.strip() and not os.getenv("WIZELIT_API_URL"):
75
+ os.environ["WIZELIT_API_URL"] = api_url.strip().rstrip("/")
76
+
77
+ env_block = config.get("env")
78
+ if not isinstance(env_block, dict):
79
+ return
80
+ for key, value in env_block.items():
81
+ if not isinstance(key, str) or not isinstance(value, str):
82
+ continue
83
+ if not os.getenv(key):
84
+ os.environ[key] = value