superwise-sentinel-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sentinel_cli/__init__.py +1 -0
- sentinel_cli/auth.py +29 -0
- sentinel_cli/cli.py +232 -0
- sentinel_cli/config.py +104 -0
- sentinel_cli/docker_manager.py +142 -0
- sentinel_cli/runner.py +52 -0
- sentinel_cli/shell_profile.py +116 -0
- sentinel_cli/test_questionary.py +8 -0
- superwise_sentinel_cli-0.1.0.dist-info/METADATA +9 -0
- superwise_sentinel_cli-0.1.0.dist-info/RECORD +12 -0
- superwise_sentinel_cli-0.1.0.dist-info/WHEEL +4 -0
- superwise_sentinel_cli-0.1.0.dist-info/entry_points.txt +2 -0
sentinel_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
sentinel_cli/auth.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
from sentinel_cli.config import AppConfig
|
|
3
|
+
from sentinel_cli.config import CONFIG_DIR
|
|
4
|
+
|
|
5
|
+
_TOKEN_FILE = CONFIG_DIR / "token"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def authenticate(
|
|
9
|
+
client_id: str | None = None,
|
|
10
|
+
client_secret: str | None = None,
|
|
11
|
+
auth_host: str | None = None,
|
|
12
|
+
force: bool = False,
|
|
13
|
+
) -> str:
|
|
14
|
+
if not force and _TOKEN_FILE.exists():
|
|
15
|
+
return _TOKEN_FILE.read_text()
|
|
16
|
+
|
|
17
|
+
cfg = AppConfig.load()
|
|
18
|
+
url = (auth_host or cfg.sw_auth_host) + "/identity/resources/auth/v1/api-token"
|
|
19
|
+
headers = {"accept": "application/json", "content-type": "application/json"}
|
|
20
|
+
body = {"clientId": client_id or cfg.superwise_client_id, "secret": client_secret or cfg.superwise_client_secret}
|
|
21
|
+
response = requests.post(url, headers=headers, json=body, timeout=10)
|
|
22
|
+
if response.status_code == 401:
|
|
23
|
+
raise RuntimeError("Unauthorized.")
|
|
24
|
+
else:
|
|
25
|
+
response.raise_for_status()
|
|
26
|
+
token = response.json()["accessToken"]
|
|
27
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
_TOKEN_FILE.write_text(token)
|
|
29
|
+
return token
|
sentinel_cli/cli.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from sentinel_cli.auth import authenticate
|
|
7
|
+
from sentinel_cli.config import AppConfig
|
|
8
|
+
from sentinel_cli.config import VALID_PROVIDERS
|
|
9
|
+
from sentinel_cli.docker_manager import get_running_gateway_info
|
|
10
|
+
from sentinel_cli.docker_manager import stop_container
|
|
11
|
+
from sentinel_cli.runner import create_sentinel
|
|
12
|
+
from sentinel_cli.runner import get_or_create_local_endpoint
|
|
13
|
+
from sentinel_cli.runner import resolve_env_vars
|
|
14
|
+
from sentinel_cli.shell_profile import remove_env_vars
|
|
15
|
+
from sentinel_cli.shell_profile import write_env_vars
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_SECRET_PLACEHOLDER = "********"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _prompt_uuid(label: str, default: str | None, hide_input: bool = False) -> str | None:
|
|
22
|
+
display_default = _SECRET_PLACEHOLDER if (hide_input and default) else (default or "")
|
|
23
|
+
while True:
|
|
24
|
+
raw = click.prompt(label, default=display_default, hide_input=hide_input)
|
|
25
|
+
value = None if raw in ("", _SECRET_PLACEHOLDER) else raw
|
|
26
|
+
if value is None:
|
|
27
|
+
return default if (hide_input and raw == _SECRET_PLACEHOLDER) else None
|
|
28
|
+
try:
|
|
29
|
+
uuid.UUID(value)
|
|
30
|
+
return value
|
|
31
|
+
except ValueError:
|
|
32
|
+
click.echo(f"Invalid UUID: {value!r}. Please try again.")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def echo_error(message: str) -> None:
|
|
36
|
+
click.echo(click.style(message, fg="red"), err=True)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _load_config() -> AppConfig:
|
|
40
|
+
try:
|
|
41
|
+
return AppConfig.load()
|
|
42
|
+
except RuntimeError as exc:
|
|
43
|
+
echo_error(f"Error loading config: {exc}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@click.group()
|
|
47
|
+
@click.version_option(prog_name="superwise-sentinel-cli", help="Show the version and exit.")
|
|
48
|
+
def main() -> None:
|
|
49
|
+
"""Superwise Sentinel - Your AI Security and Optimization Gateway.
|
|
50
|
+
|
|
51
|
+
Sentinel is an LLM proxy that intercepts traffic, enabling you to monitor, secure, and optimize your AI usage.
|
|
52
|
+
|
|
53
|
+
This CLI tool helps you quickly set up traffic interception on this machine.
|
|
54
|
+
Default or existing values are displayed in brackets. Press Enter to accept existing or provide a new value.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@main.command("quickstart")
|
|
59
|
+
@click.option(
|
|
60
|
+
"--new-sentinel", is_flag=True, default=False, help="Create a new sentinel even if an ID is already configured."
|
|
61
|
+
)
|
|
62
|
+
@click.pass_context
|
|
63
|
+
def quickstart_flow(ctx, new_sentinel: bool) -> None:
|
|
64
|
+
"""Configure your first sentinel proxy."""
|
|
65
|
+
if new_sentinel or not click.confirm("Do you want to use an existing gateway?"):
|
|
66
|
+
ctx.invoke(start_gateway, new_sentinel=new_sentinel)
|
|
67
|
+
else:
|
|
68
|
+
ctx.invoke(config_url)
|
|
69
|
+
ctx.invoke(config_providers)
|
|
70
|
+
ctx.invoke(start_proxy)
|
|
71
|
+
click.echo(
|
|
72
|
+
"Sentinel proxy is ready to intercept traffic! Any new session you open will be protected by the Sentinel"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@main.command("auth")
|
|
77
|
+
@click.option("--advanced", is_flag=True, default=False, help="Show host override options.")
|
|
78
|
+
def auth(advanced: bool) -> None:
|
|
79
|
+
"""Authenticate with Superwise."""
|
|
80
|
+
cfg = AppConfig.load()
|
|
81
|
+
new_client_id = _prompt_uuid("Superwise Client ID", cfg.superwise_client_id)
|
|
82
|
+
new_client_secret = _prompt_uuid("Superwise Client Secret", cfg.superwise_client_secret, hide_input=True)
|
|
83
|
+
new_auth_host = cfg.sw_auth_host
|
|
84
|
+
new_api_host = cfg.sw_api_host
|
|
85
|
+
if advanced:
|
|
86
|
+
new_auth_host = click.prompt("SW_AUTH_HOST", default=cfg.sw_auth_host)
|
|
87
|
+
new_api_host = click.prompt("SW_API_HOST", default=cfg.sw_api_host)
|
|
88
|
+
|
|
89
|
+
credentials_changed = (
|
|
90
|
+
new_client_id != cfg.superwise_client_id
|
|
91
|
+
or new_client_secret != cfg.superwise_client_secret
|
|
92
|
+
or new_auth_host != cfg.sw_auth_host
|
|
93
|
+
)
|
|
94
|
+
if credentials_changed:
|
|
95
|
+
try:
|
|
96
|
+
authenticate(
|
|
97
|
+
force=True,
|
|
98
|
+
client_id=new_client_id,
|
|
99
|
+
client_secret=new_client_secret,
|
|
100
|
+
auth_host=new_auth_host,
|
|
101
|
+
)
|
|
102
|
+
except Exception as exc:
|
|
103
|
+
raise click.ClickException(str(exc)) from exc
|
|
104
|
+
|
|
105
|
+
cfg.superwise_client_id = new_client_id
|
|
106
|
+
cfg.superwise_client_secret = new_client_secret
|
|
107
|
+
cfg.sw_auth_host = new_auth_host
|
|
108
|
+
cfg.sw_api_host = new_api_host
|
|
109
|
+
cfg.save()
|
|
110
|
+
click.echo("Authentication successful.")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@main.group()
|
|
114
|
+
def gateway() -> None:
|
|
115
|
+
"""Manage gateway deployment on this machine."""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@gateway.command("start")
|
|
119
|
+
@click.option("--sentinel-id", type=click.UUID, help="Provide preconfigured sentinel ID.")
|
|
120
|
+
@click.option(
|
|
121
|
+
"--new-sentinel", is_flag=True, default=False, help="Create a new sentinel even if an ID is already configured."
|
|
122
|
+
)
|
|
123
|
+
@click.pass_context
|
|
124
|
+
def start_gateway(ctx, sentinel_id: uuid.UUID | None, new_sentinel: bool) -> None:
|
|
125
|
+
"""Bootstrap & run a local Sentinel gateway container."""
|
|
126
|
+
cfg = _load_config()
|
|
127
|
+
|
|
128
|
+
running = get_running_gateway_info()
|
|
129
|
+
if running is not None:
|
|
130
|
+
endpoint, running_sentinel_id = running
|
|
131
|
+
click.echo(
|
|
132
|
+
f"A local gateway is already running at {endpoint.base_url} "
|
|
133
|
+
f"(sentinel ID: {running_sentinel_id or 'unknown'})."
|
|
134
|
+
)
|
|
135
|
+
if not click.confirm("Stop it and start a new one?"):
|
|
136
|
+
return
|
|
137
|
+
stop_container()
|
|
138
|
+
|
|
139
|
+
if sentinel_id:
|
|
140
|
+
cfg.sentinel_id = str(sentinel_id)
|
|
141
|
+
elif new_sentinel or not cfg.sentinel_id:
|
|
142
|
+
click.echo("Creating a new sentinel.")
|
|
143
|
+
if not cfg.superwise_client_secret or not cfg.superwise_client_id:
|
|
144
|
+
click.echo("Authentication is required.")
|
|
145
|
+
ctx.invoke(auth)
|
|
146
|
+
cfg = AppConfig.load()
|
|
147
|
+
try:
|
|
148
|
+
created_id, created_name = create_sentinel(cfg)
|
|
149
|
+
cfg.sentinel_id = created_id
|
|
150
|
+
click.echo(f"New sentinel '{created_name}' created. Sentinel ID: {cfg.sentinel_id}")
|
|
151
|
+
except Exception as exc:
|
|
152
|
+
raise click.ClickException(str(exc)) from exc
|
|
153
|
+
|
|
154
|
+
url = get_or_create_local_endpoint(cfg)
|
|
155
|
+
cfg.gateway_url = url
|
|
156
|
+
cfg.save()
|
|
157
|
+
click.echo(f"Gateway running on {url} with sentinel ID: {cfg.sentinel_id}. Configuration updated to match.")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@gateway.command("stop")
|
|
161
|
+
def stop_gateway() -> None:
|
|
162
|
+
"""Stop and remove the local gateway container."""
|
|
163
|
+
try:
|
|
164
|
+
stop_container()
|
|
165
|
+
except RuntimeError as exc:
|
|
166
|
+
raise click.ClickException(str(exc)) from exc
|
|
167
|
+
click.echo("Gateway removed.")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@main.group("config")
|
|
171
|
+
def config() -> None:
|
|
172
|
+
"""Configure gateway URL and LLM providers."""
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@config.command("url")
|
|
176
|
+
def config_url() -> None:
|
|
177
|
+
"""Set the gateway URL."""
|
|
178
|
+
cfg = AppConfig.load()
|
|
179
|
+
cfg.gateway_url = click.prompt("Gateway url", default=cfg.gateway_url)
|
|
180
|
+
cfg.save()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@config.command("providers")
|
|
184
|
+
def config_providers() -> None:
|
|
185
|
+
"""Set the LLM providers to intercept. Use 'all' to select all supported providers."""
|
|
186
|
+
cfg = AppConfig.load()
|
|
187
|
+
while True:
|
|
188
|
+
providers = click.prompt("Providers (space separated)", default=" ".join(cfg.providers)).split()
|
|
189
|
+
if providers == ["all"]:
|
|
190
|
+
providers = VALID_PROVIDERS
|
|
191
|
+
invalid = [p for p in providers if p not in VALID_PROVIDERS]
|
|
192
|
+
if invalid:
|
|
193
|
+
echo_error(f"Unknown provider(s): {', '.join(invalid)}")
|
|
194
|
+
echo_error(f"Valid choices: {', '.join(VALID_PROVIDERS)}")
|
|
195
|
+
continue
|
|
196
|
+
break
|
|
197
|
+
cfg.providers = providers
|
|
198
|
+
cfg.save()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@main.group()
|
|
202
|
+
def proxy() -> None:
|
|
203
|
+
"""Toggle traffic interception."""
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@proxy.command("on")
|
|
207
|
+
@click.pass_context
|
|
208
|
+
def start_proxy(ctx) -> None:
|
|
209
|
+
"""Start traffic interception to configured providers.
|
|
210
|
+
|
|
211
|
+
Changes apply to new shell sessions."""
|
|
212
|
+
cfg = AppConfig.load()
|
|
213
|
+
|
|
214
|
+
if not cfg.gateway_url:
|
|
215
|
+
ctx.invoke(config_url)
|
|
216
|
+
if not cfg.providers:
|
|
217
|
+
ctx.invoke(config_providers)
|
|
218
|
+
|
|
219
|
+
pairs = resolve_env_vars(cfg.providers, cfg.gateway_url)
|
|
220
|
+
remove_env_vars()
|
|
221
|
+
write_env_vars(pairs)
|
|
222
|
+
|
|
223
|
+
for var, u in pairs:
|
|
224
|
+
click.echo(f" {var}={u}", err=True)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@proxy.command("off")
|
|
228
|
+
def stop_proxy() -> None:
|
|
229
|
+
"""Stop traffic interception."""
|
|
230
|
+
removed = remove_env_vars()
|
|
231
|
+
if removed:
|
|
232
|
+
click.echo(f"Proxy off: {', '.join(removed)}", err=True)
|
sentinel_cli/config.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
import uuid
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
from pydantic import field_validator
|
|
12
|
+
from pydantic.fields import FieldInfo
|
|
13
|
+
from pydantic_settings import BaseSettings
|
|
14
|
+
from pydantic_settings import PydanticBaseSettingsSource
|
|
15
|
+
from pydantic_settings import SettingsConfigDict
|
|
16
|
+
|
|
17
|
+
IS_WINDOWS = platform.system() == "Windows"
|
|
18
|
+
IS_MACOS = platform.system() == "Darwin"
|
|
19
|
+
|
|
20
|
+
if IS_WINDOWS:
|
|
21
|
+
CONFIG_DIR = Path(os.environ.get("APPDATA", Path.home())) / "sentinel"
|
|
22
|
+
else:
|
|
23
|
+
CONFIG_DIR = Path.home() / ".config" / "sentinel"
|
|
24
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
25
|
+
|
|
26
|
+
_PROVIDER_CONFIG: dict[str, tuple[str, str]] = {
|
|
27
|
+
"anthropic": ("ANTHROPIC_BASE_URL", "anthropic"),
|
|
28
|
+
"openai": ("OPENAI_BASE_URL", "openai"),
|
|
29
|
+
"google": ("GOOGLE_API_ENDPOINT", "google"),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
VALID_PROVIDERS = list(_PROVIDER_CONFIG.keys())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _JsonFileSource(PydanticBaseSettingsSource):
|
|
36
|
+
"""Settings source that reads from the JSON config file on disk."""
|
|
37
|
+
|
|
38
|
+
def _read(self) -> dict[str, Any]:
|
|
39
|
+
if not CONFIG_FILE.exists():
|
|
40
|
+
return {}
|
|
41
|
+
try:
|
|
42
|
+
return json.loads(CONFIG_FILE.read_text())
|
|
43
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
44
|
+
raise RuntimeError(f"Malformed config at {CONFIG_FILE}: {exc}") from exc
|
|
45
|
+
|
|
46
|
+
def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
|
|
47
|
+
return self._read().get(field_name), field_name, False
|
|
48
|
+
|
|
49
|
+
def __call__(self) -> dict[str, Any]:
|
|
50
|
+
return self._read()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AppConfig(BaseSettings):
|
|
54
|
+
model_config = SettingsConfigDict(
|
|
55
|
+
env_prefix="SENTINEL_",
|
|
56
|
+
env_ignore_empty=True,
|
|
57
|
+
validate_assignment=True,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
gateway_url: str | None = None
|
|
61
|
+
superwise_client_id: str | None = None
|
|
62
|
+
superwise_client_secret: str | None = None
|
|
63
|
+
sentinel_id: str | None = None
|
|
64
|
+
providers: list[str] = VALID_PROVIDERS
|
|
65
|
+
sw_api_host: str = "https://api.superwise.ai"
|
|
66
|
+
sw_auth_host: str = "https://authentication.superwise.ai"
|
|
67
|
+
|
|
68
|
+
@field_validator("sentinel_id", "superwise_client_id", "superwise_client_secret")
|
|
69
|
+
@classmethod
|
|
70
|
+
def must_be_uuid(cls, v: str | None) -> str | None:
|
|
71
|
+
if v is None:
|
|
72
|
+
return v
|
|
73
|
+
try:
|
|
74
|
+
uuid.UUID(v)
|
|
75
|
+
except ValueError:
|
|
76
|
+
raise ValueError(f"must be a valid UUID, got: {v!r}")
|
|
77
|
+
return v
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def settings_customise_sources(
|
|
81
|
+
cls,
|
|
82
|
+
settings_cls: type[BaseSettings],
|
|
83
|
+
init_settings: PydanticBaseSettingsSource,
|
|
84
|
+
env_settings: PydanticBaseSettingsSource,
|
|
85
|
+
dotenv_settings: PydanticBaseSettingsSource,
|
|
86
|
+
file_secret_settings: PydanticBaseSettingsSource,
|
|
87
|
+
) -> tuple[PydanticBaseSettingsSource, ...]:
|
|
88
|
+
# env vars override the JSON file; both override field defaults.
|
|
89
|
+
return init_settings, env_settings, _JsonFileSource(settings_cls)
|
|
90
|
+
|
|
91
|
+
def save(self) -> None:
|
|
92
|
+
"""Persist configuration to disk, creating the directory if absent."""
|
|
93
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
CONFIG_FILE.write_text(self.model_dump_json(indent=2))
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def load(cls) -> "AppConfig":
|
|
98
|
+
"""Instantiate from all sources (env vars > JSON file > defaults)."""
|
|
99
|
+
try:
|
|
100
|
+
return cls()
|
|
101
|
+
except RuntimeError:
|
|
102
|
+
raise
|
|
103
|
+
except Exception as exc:
|
|
104
|
+
raise RuntimeError(f"Failed to load config: {exc}") from exc
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
import docker.errors
|
|
8
|
+
from docker.models.containers import Container
|
|
9
|
+
|
|
10
|
+
CONTAINER_IMAGE = "us-central1-docker.pkg.dev/admina33d6818/docker/platform/sentinel:2635076005"
|
|
11
|
+
CONTAINER_NAME = "sentinel-local"
|
|
12
|
+
CONTAINER_PORT = 8000
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class ContainerEndpoint:
|
|
17
|
+
host: str
|
|
18
|
+
port: int
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def base_url(self) -> str:
|
|
22
|
+
return f"http://{self.host}:{self.port}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _docker_client() -> docker.DockerClient:
|
|
26
|
+
try:
|
|
27
|
+
client = docker.from_env()
|
|
28
|
+
client.ping()
|
|
29
|
+
return client
|
|
30
|
+
except docker.errors.DockerException as exc:
|
|
31
|
+
raise RuntimeError("Cannot connect to Docker daemon — is Docker running?") from exc
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _running_container(client: docker.DockerClient) -> Container | None:
|
|
35
|
+
matches = client.containers.list(filters={"name": CONTAINER_NAME, "status": "running"})
|
|
36
|
+
return matches[0] if matches else None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _container_env(container: Container) -> dict[str, str]:
|
|
40
|
+
container.reload()
|
|
41
|
+
return dict(pair.split("=", 1) for pair in container.attrs["Config"]["Env"] if "=" in pair)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _endpoint_from_container(container: Container) -> ContainerEndpoint:
|
|
45
|
+
container.reload()
|
|
46
|
+
binding = container.ports.get(f"{CONTAINER_PORT}/tcp")
|
|
47
|
+
if binding:
|
|
48
|
+
host = binding[0]["HostIp"] or "127.0.0.1"
|
|
49
|
+
host = "127.0.0.1" if host in ("0.0.0.0", "") else host
|
|
50
|
+
return ContainerEndpoint(host=host, port=int(binding[0]["HostPort"]))
|
|
51
|
+
|
|
52
|
+
# No published port — use the container's bridge network IP directly.
|
|
53
|
+
ip = container.attrs["NetworkSettings"].get("IPAddress") or "127.0.0.1"
|
|
54
|
+
return ContainerEndpoint(host=ip, port=CONTAINER_PORT)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_running_gateway_info() -> tuple[ContainerEndpoint, str | None] | None:
|
|
58
|
+
"""Return (endpoint, sentinel_id) if the local container is running, else None."""
|
|
59
|
+
try:
|
|
60
|
+
client = _docker_client()
|
|
61
|
+
except RuntimeError:
|
|
62
|
+
return None
|
|
63
|
+
container = _running_container(client)
|
|
64
|
+
if container is None:
|
|
65
|
+
return None
|
|
66
|
+
return _endpoint_from_container(container), _container_env(container).get("SENTINEL_ID")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def stop_container() -> None:
|
|
70
|
+
"""Stop and remove the sentinel container. Returns True if it was running."""
|
|
71
|
+
client = _docker_client()
|
|
72
|
+
matches = client.containers.list(all=True, filters={"name": CONTAINER_NAME})
|
|
73
|
+
for container in matches:
|
|
74
|
+
container.stop(timeout=5)
|
|
75
|
+
container.remove(force=True)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _docker_pull(platform: str | None = None) -> subprocess.CompletedProcess:
|
|
79
|
+
cmd = ["docker", "pull"]
|
|
80
|
+
if platform:
|
|
81
|
+
cmd += ["--platform", platform]
|
|
82
|
+
cmd.append(CONTAINER_IMAGE)
|
|
83
|
+
return subprocess.run(cmd, stderr=subprocess.PIPE, text=True)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _pull_image_if_needed() -> str | None:
|
|
87
|
+
"""Pull CONTAINER_IMAGE if not present locally. Returns the platform override if amd64 fallback was needed."""
|
|
88
|
+
client = _docker_client()
|
|
89
|
+
try:
|
|
90
|
+
client.images.get(CONTAINER_IMAGE)
|
|
91
|
+
return None
|
|
92
|
+
except docker.errors.ImageNotFound:
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
click.echo(f"Pulling sentinel image...")
|
|
96
|
+
native = _docker_pull()
|
|
97
|
+
if native.returncode == 0:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
if "no matching manifest" in native.stderr:
|
|
101
|
+
click.echo("Native platform image not available, falling back to linux/amd64...")
|
|
102
|
+
fallback = _docker_pull("linux/amd64")
|
|
103
|
+
if fallback.returncode == 0:
|
|
104
|
+
return "linux/amd64"
|
|
105
|
+
raise RuntimeError(f"Failed to pull image {CONTAINER_IMAGE}:\n{fallback.stderr}") from None
|
|
106
|
+
|
|
107
|
+
raise RuntimeError(f"Failed to pull image {CONTAINER_IMAGE}:\n{native.stderr}") from None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def ensure_container_running(environment: dict[str, str] | None = None) -> ContainerEndpoint:
|
|
111
|
+
"""Return the endpoint of the local container, starting it if needed."""
|
|
112
|
+
client = _docker_client()
|
|
113
|
+
|
|
114
|
+
existing = _running_container(client)
|
|
115
|
+
if existing:
|
|
116
|
+
current_env = _container_env(existing)
|
|
117
|
+
requested_env = environment or {}
|
|
118
|
+
if all(current_env.get(k) == v for k, v in requested_env.items()):
|
|
119
|
+
return _endpoint_from_container(existing)
|
|
120
|
+
click.echo("Sentinel config changed, reloading container...")
|
|
121
|
+
existing.stop(timeout=5)
|
|
122
|
+
existing.remove()
|
|
123
|
+
|
|
124
|
+
for container in client.containers.list(all=True, filters={"name": CONTAINER_NAME}):
|
|
125
|
+
container.remove()
|
|
126
|
+
|
|
127
|
+
platform = _pull_image_if_needed()
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
container: Container = client.containers.run(
|
|
131
|
+
CONTAINER_IMAGE,
|
|
132
|
+
name=CONTAINER_NAME,
|
|
133
|
+
ports={f"{CONTAINER_PORT}/tcp": None},
|
|
134
|
+
environment=environment or {},
|
|
135
|
+
**({} if platform is None else {"platform": platform}),
|
|
136
|
+
detach=True,
|
|
137
|
+
remove=False,
|
|
138
|
+
)
|
|
139
|
+
except docker.errors.APIError as exc:
|
|
140
|
+
raise RuntimeError(f"Failed to start sentinel container: {exc.explanation}") from exc
|
|
141
|
+
|
|
142
|
+
return _endpoint_from_container(container)
|
sentinel_cli/runner.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
from sentinel_cli.auth import authenticate
|
|
5
|
+
from sentinel_cli.config import _PROVIDER_CONFIG
|
|
6
|
+
from sentinel_cli.config import AppConfig
|
|
7
|
+
from sentinel_cli.docker_manager import ensure_container_running
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _post_sentinel(cfg: AppConfig, token: str) -> requests.Response:
|
|
11
|
+
return requests.post(
|
|
12
|
+
cfg.sw_api_host + "/v1/sentinels",
|
|
13
|
+
headers={
|
|
14
|
+
"Content-Type": "application/json",
|
|
15
|
+
"Accept": "application/json",
|
|
16
|
+
"Authorization": f"Bearer {token}",
|
|
17
|
+
},
|
|
18
|
+
json={},
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def create_sentinel(cfg: AppConfig) -> tuple[str, str]:
|
|
23
|
+
response = _post_sentinel(cfg, authenticate())
|
|
24
|
+
if response.status_code == 401:
|
|
25
|
+
response = _post_sentinel(cfg, authenticate(force=True))
|
|
26
|
+
response.raise_for_status()
|
|
27
|
+
response_json = response.json()
|
|
28
|
+
return response_json["id"], response_json["name"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_or_create_local_endpoint(config: AppConfig) -> str:
|
|
32
|
+
env = {
|
|
33
|
+
"SENTINEL_ID": config.sentinel_id or "",
|
|
34
|
+
"SW_CLIENT_ID": config.superwise_client_id or "",
|
|
35
|
+
"SW_CLIENT_SECRET": config.superwise_client_secret or "",
|
|
36
|
+
}
|
|
37
|
+
if config.sw_api_host:
|
|
38
|
+
env["SW_API_HOST"] = config.sw_api_host
|
|
39
|
+
if config.sw_auth_host:
|
|
40
|
+
env["SW_AUTH_HOST"] = config.sw_auth_host
|
|
41
|
+
endpoint = ensure_container_running(environment=env)
|
|
42
|
+
return endpoint.base_url
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def resolve_env_vars(providers: list[str], base_url: str) -> list[tuple[str, str]]:
|
|
46
|
+
results: list[tuple[str, str]] = []
|
|
47
|
+
|
|
48
|
+
for provider in providers:
|
|
49
|
+
env_var, path = _PROVIDER_CONFIG[provider]
|
|
50
|
+
results.append((env_var, f"{base_url}/{path}"))
|
|
51
|
+
|
|
52
|
+
return results
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from sentinel_cli.config import CONFIG_DIR
|
|
10
|
+
from sentinel_cli.config import IS_MACOS
|
|
11
|
+
from sentinel_cli.config import IS_WINDOWS
|
|
12
|
+
|
|
13
|
+
_STATE_FILE = CONFIG_DIR / "protected_vars.json"
|
|
14
|
+
_SOURCE_MARKER = "# sentinel-env"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class ShellContext:
|
|
19
|
+
env_file: Path
|
|
20
|
+
profile_file: Path
|
|
21
|
+
source_line: str
|
|
22
|
+
|
|
23
|
+
def export_stmt(self, var: str, url: str) -> str:
|
|
24
|
+
raise NotImplementedError
|
|
25
|
+
|
|
26
|
+
def unset_stmt(self, var: str) -> str:
|
|
27
|
+
raise NotImplementedError
|
|
28
|
+
|
|
29
|
+
@staticmethod
|
|
30
|
+
def get_platform_context() -> ShellContext:
|
|
31
|
+
if IS_WINDOWS:
|
|
32
|
+
env_file = CONFIG_DIR / "env.ps1"
|
|
33
|
+
profile_file = (
|
|
34
|
+
Path(os.environ.get("USERPROFILE", Path.home()))
|
|
35
|
+
/ "Documents"
|
|
36
|
+
/ "WindowsPowerShell"
|
|
37
|
+
/ "Microsoft.PowerShell_profile.ps1"
|
|
38
|
+
)
|
|
39
|
+
source_line = f"{_SOURCE_MARKER}\nif (Test-Path '{env_file}') {{ . '{env_file}' }}"
|
|
40
|
+
return _WindowsShellContext(env_file=env_file, profile_file=profile_file, source_line=source_line)
|
|
41
|
+
|
|
42
|
+
env_file = CONFIG_DIR / "env.sh"
|
|
43
|
+
shell = os.environ.get("SHELL", "")
|
|
44
|
+
profile_file = Path.home() / (".bashrc" if "bash" in shell else ".zshrc")
|
|
45
|
+
source_line = f"{_SOURCE_MARKER}\n[ -f '{env_file}' ] && . '{env_file}'"
|
|
46
|
+
return _UnixShellContext(env_file=env_file, profile_file=profile_file, source_line=source_line)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class _WindowsShellContext(ShellContext):
|
|
51
|
+
def export_stmt(self, var: str, url: str) -> str:
|
|
52
|
+
return f'$env:{var} = "{url}"'
|
|
53
|
+
|
|
54
|
+
def unset_stmt(self, var: str) -> str:
|
|
55
|
+
return f"Remove-Item Env:{var}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class _UnixShellContext(ShellContext):
|
|
60
|
+
def export_stmt(self, var: str, url: str) -> str:
|
|
61
|
+
return f"export {var}='{url}'"
|
|
62
|
+
|
|
63
|
+
def unset_stmt(self, var: str) -> str:
|
|
64
|
+
return f"unset {var}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
SHELL = ShellContext.get_platform_context()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _ensure_profile_sources_env() -> None:
|
|
71
|
+
SHELL.profile_file.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
text = SHELL.profile_file.read_text() if SHELL.profile_file.exists() else ""
|
|
73
|
+
if _SOURCE_MARKER not in text:
|
|
74
|
+
SHELL.profile_file.write_text(text.rstrip("\n") + f"\n\n{SHELL.source_line}\n")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _launchctl(action: str, var: str, value: str = "") -> None:
|
|
78
|
+
if not IS_MACOS:
|
|
79
|
+
return
|
|
80
|
+
args = ["launchctl", action, var]
|
|
81
|
+
if value:
|
|
82
|
+
args.append(value)
|
|
83
|
+
subprocess.run(args, check=False)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def read_env_vars() -> list[tuple[str, str]]:
|
|
87
|
+
if not _STATE_FILE.exists():
|
|
88
|
+
return []
|
|
89
|
+
try:
|
|
90
|
+
return list(json.loads(_STATE_FILE.read_text()).items())
|
|
91
|
+
except (json.JSONDecodeError, ValueError):
|
|
92
|
+
return []
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def write_env_vars(pairs: list[tuple[str, str]]) -> None:
|
|
96
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
_STATE_FILE.write_text(json.dumps(dict(pairs), indent=2))
|
|
98
|
+
|
|
99
|
+
env_lines = "\n".join(SHELL.export_stmt(var, url) for var, url in pairs)
|
|
100
|
+
SHELL.env_file.write_text(env_lines + "\n")
|
|
101
|
+
|
|
102
|
+
_ensure_profile_sources_env()
|
|
103
|
+
|
|
104
|
+
for var, url in pairs:
|
|
105
|
+
_launchctl("setenv", var, url)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def remove_env_vars() -> list[str]:
|
|
109
|
+
vars_removed = [var for var, _ in read_env_vars()]
|
|
110
|
+
for var in vars_removed:
|
|
111
|
+
_launchctl("unsetenv", var)
|
|
112
|
+
if _STATE_FILE.exists():
|
|
113
|
+
_STATE_FILE.unlink()
|
|
114
|
+
if SHELL.env_file.exists():
|
|
115
|
+
SHELL.env_file.unlink()
|
|
116
|
+
return vars_removed
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: superwise-sentinel-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Sentinel is an LLM proxy that intercepts traffic, enabling you to monitor, secure, and optimize your AI usage. This CLI tool helps you quickly set up traffic interception on this machine
|
|
5
|
+
Requires-Python: <4.0,>=3.11
|
|
6
|
+
Requires-Dist: click>=8.1
|
|
7
|
+
Requires-Dist: docker>=7.0
|
|
8
|
+
Requires-Dist: pydantic-settings>=2.3
|
|
9
|
+
Requires-Dist: pydantic>=2.0
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
sentinel_cli/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
sentinel_cli/auth.py,sha256=4rGPeN5RlLw12ooRv4jfgwilIFXs3Ie04R1QB1GKArc,1035
|
|
3
|
+
sentinel_cli/cli.py,sha256=GFLrTNWX5CtB820J169dYekemEMY7pgmjjnS0aiG5hs,7895
|
|
4
|
+
sentinel_cli/config.py,sha256=U6WqLApb-_wXv-LNwEkPvPuUxeyX-BEpkHJ7-4avPpY,3479
|
|
5
|
+
sentinel_cli/docker_manager.py,sha256=gvU9HQ_VsLtAHLBGcNhfAwwD3Vrin1aFHzHLV3H29LE,4964
|
|
6
|
+
sentinel_cli/runner.py,sha256=7EpEvtwrwOpVlyTXmIPgobwaZNQsc0LOrkTWUGQ38rA,1690
|
|
7
|
+
sentinel_cli/shell_profile.py,sha256=PrPk3W79EVOjljmVG88C61XKsV5Dv44eNR68rvRFEGc,3546
|
|
8
|
+
sentinel_cli/test_questionary.py,sha256=72CbzX7P_U3fhNWOH3EVALTc3i7Ar2zCD5b7zDSOajY,172
|
|
9
|
+
superwise_sentinel_cli-0.1.0.dist-info/METADATA,sha256=p0Cl2Xh7cd8J7TLi9Sbfhm-Zb_ynoLjKmReCj0HHtc4,411
|
|
10
|
+
superwise_sentinel_cli-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
11
|
+
superwise_sentinel_cli-0.1.0.dist-info/entry_points.txt,sha256=kiePIAd98HyQLsl0gWBZ4a_AmDFB2bXZyx_OuXqejAs,61
|
|
12
|
+
superwise_sentinel_cli-0.1.0.dist-info/RECORD,,
|