envloop-cli 0.1.0__py3-none-any.whl → 0.2.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.
- envloop/__init__.py +5 -0
- envloop/agent_runs.py +208 -0
- envloop/auth_cli.py +285 -0
- envloop/auth_login.py +127 -0
- envloop/auth_workspace.py +290 -0
- envloop/cli.py +452 -0
- envloop/cli_output.py +206 -0
- envloop/client.py +548 -0
- envloop/config.py +440 -0
- envloop/http.py +274 -0
- envloop/lifecycle_cli.py +53 -0
- envloop/navigation_resources.py +260 -0
- envloop/resource_cli.py +164 -0
- envloop/resources.py +210 -0
- envloop/train.py +51 -0
- envloop/training/__init__.py +128 -0
- envloop/training/credentials.py +56 -0
- envloop/training/service_client.py +40 -0
- envloop_cli-0.2.0.dist-info/METADATA +101 -0
- envloop_cli-0.2.0.dist-info/RECORD +24 -0
- envloop_cli-0.2.0.dist-info/entry_points.txt +3 -0
- el_cli/__init__.py +0 -1
- el_cli/cli.py +0 -257
- el_cli/client.py +0 -335
- el_cli/http.py +0 -115
- el_cli/train.py +0 -53
- envloop_cli-0.1.0.dist-info/METADATA +0 -89
- envloop_cli-0.1.0.dist-info/RECORD +0 -11
- envloop_cli-0.1.0.dist-info/entry_points.txt +0 -2
- {el_cli → envloop}/utils/__init__.py +0 -0
- {el_cli → envloop}/utils/logging.py +0 -0
- {envloop_cli-0.1.0.dist-info → envloop_cli-0.2.0.dist-info}/WHEEL +0 -0
envloop/__init__.py
ADDED
envloop/agent_runs.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Agent runs: a read-only view of Train Jobs grouped by their agent's run label.
|
|
2
|
+
|
|
3
|
+
The platform stores no run object. Agents tag every Train Job they create with
|
|
4
|
+
``user_metadata`` (the RSI loop: ``rsi_run``, ``rsi_round``, ``rsi_checkpoint``...);
|
|
5
|
+
this module pages through ``GET /v1/train/jobs`` and derives runs from those tags,
|
|
6
|
+
using the same rules as the Console's agent runs page.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
|
|
11
|
+
from .train import TrainJobs
|
|
12
|
+
|
|
13
|
+
# The generic ``agent_*`` key wins over the RSI loop's ``rsi_*`` one (the Console's rule).
|
|
14
|
+
KEYS = {
|
|
15
|
+
"run": ("agent_run", "rsi_run"),
|
|
16
|
+
"round": ("agent_round", "rsi_round"),
|
|
17
|
+
"purpose": ("agent_purpose", "rsi_purpose"),
|
|
18
|
+
"method": ("agent_method", "rsi_method"),
|
|
19
|
+
"checkpoint": ("agent_checkpoint", "rsi_checkpoint"),
|
|
20
|
+
}
|
|
21
|
+
RUN_KEYS = KEYS["run"] # first non-empty string wins
|
|
22
|
+
ACTIVE_STATUSES = frozenset({"LAUNCHING", "STARTING", "RUNNING", "RECOVERING"})
|
|
23
|
+
TERMINAL_STATUSES = frozenset({"SUCCEEDED", "FAILED", "CANCELLED"})
|
|
24
|
+
PAGE_LIMIT = 100 # the platform's maximum page size
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AgentRunError(RuntimeError):
|
|
28
|
+
"""A run lookup or listing failed; ``code`` is safe to print."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, code: str) -> None:
|
|
31
|
+
self.code = code
|
|
32
|
+
super().__init__(code)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _metadata(job) -> dict:
|
|
36
|
+
metadata = job.get("user_metadata")
|
|
37
|
+
return metadata if isinstance(metadata, dict) else {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _field(job, name: str):
|
|
41
|
+
metadata = _metadata(job)
|
|
42
|
+
for key in KEYS[name]:
|
|
43
|
+
value = metadata.get(key)
|
|
44
|
+
if value is not None and value != "":
|
|
45
|
+
return value
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def run_key(job) -> str | None:
|
|
50
|
+
"""The run a Train Job belongs to, or None when it carries no run label."""
|
|
51
|
+
metadata = _metadata(job)
|
|
52
|
+
for key in RUN_KEYS:
|
|
53
|
+
value = metadata.get(key)
|
|
54
|
+
if isinstance(value, str) and value.strip():
|
|
55
|
+
return value
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _round(job) -> int | None:
|
|
60
|
+
value = _field(job, "round")
|
|
61
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
62
|
+
return value
|
|
63
|
+
if isinstance(value, str):
|
|
64
|
+
try:
|
|
65
|
+
return int(value.strip())
|
|
66
|
+
except ValueError:
|
|
67
|
+
return None
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _order(job):
|
|
72
|
+
# Creation order, as the platform pages jobs (newest first, reversed here).
|
|
73
|
+
return (str(job.get("created_at") or ""), str(job.get("train_job_id") or ""))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _duration_sec(job) -> int | None:
|
|
77
|
+
if job.get("status") not in TERMINAL_STATUSES:
|
|
78
|
+
return None
|
|
79
|
+
try:
|
|
80
|
+
seconds = (datetime.fromisoformat(job["updated_at"])
|
|
81
|
+
- datetime.fromisoformat(job["created_at"])).total_seconds()
|
|
82
|
+
except (KeyError, TypeError, ValueError):
|
|
83
|
+
return None
|
|
84
|
+
return round(seconds) if seconds >= 0 else None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _checkpoint(job) -> dict | None:
|
|
88
|
+
"""The checkpoint a job produced: its agent's version name and platform path."""
|
|
89
|
+
stored = job.get("checkpoint")
|
|
90
|
+
path = stored.get("path") if isinstance(stored, dict) else None
|
|
91
|
+
name = _field(job, "checkpoint")
|
|
92
|
+
if not isinstance(path, str) or not path:
|
|
93
|
+
return None
|
|
94
|
+
return {"name": name, "path": path}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _job_view(job) -> dict:
|
|
98
|
+
metadata = _metadata(job)
|
|
99
|
+
return {
|
|
100
|
+
"train_job_id": job.get("train_job_id"),
|
|
101
|
+
"status": job.get("status"),
|
|
102
|
+
"round": _round(job),
|
|
103
|
+
"purpose": _field(job, "purpose"),
|
|
104
|
+
"method": _field(job, "method"),
|
|
105
|
+
"transitions": list(job.get("transitions") or []),
|
|
106
|
+
"created_at": job.get("created_at"),
|
|
107
|
+
"updated_at": job.get("updated_at"),
|
|
108
|
+
"duration_sec": _duration_sec(job),
|
|
109
|
+
"base_model": job.get("base_model"),
|
|
110
|
+
"lora_rank": job.get("lora_rank"),
|
|
111
|
+
"model_id": job.get("model_id"),
|
|
112
|
+
"checkpoint": _checkpoint(job),
|
|
113
|
+
"user_metadata": metadata,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _summary(run: str, jobs: list) -> dict:
|
|
118
|
+
"""Summarize one run's jobs, given in creation order."""
|
|
119
|
+
latest = jobs[-1]
|
|
120
|
+
if any(job.get("status") in ACTIVE_STATUSES for job in jobs):
|
|
121
|
+
status = "running"
|
|
122
|
+
else:
|
|
123
|
+
status = str(latest.get("status") or "unknown").lower()
|
|
124
|
+
succeeded = [job for job in jobs if job.get("status") == "SUCCEEDED"]
|
|
125
|
+
latest_saved = next((job for job in reversed(jobs) if _checkpoint(job) is not None), None)
|
|
126
|
+
latest_checkpoint = _checkpoint(latest_saved) if latest_saved is not None else None
|
|
127
|
+
if latest_checkpoint is not None:
|
|
128
|
+
latest_checkpoint["train_job_id"] = latest_saved.get("train_job_id")
|
|
129
|
+
rounds = [number for number in map(_round, jobs) if number is not None]
|
|
130
|
+
activity = [stamp for job in jobs if isinstance(stamp := job.get("updated_at") or job.get("created_at"), str)]
|
|
131
|
+
return {
|
|
132
|
+
"run": run,
|
|
133
|
+
"agent": _metadata(latest).get("source"),
|
|
134
|
+
"status": status,
|
|
135
|
+
"latest_round": max(rounds, default=None),
|
|
136
|
+
"trainings": {"succeeded": len(succeeded), "total": len(jobs)},
|
|
137
|
+
"latest_checkpoint": latest_checkpoint,
|
|
138
|
+
"base_model": latest.get("base_model"),
|
|
139
|
+
"created_at": jobs[0].get("created_at"),
|
|
140
|
+
"updated_at": max(activity, default=None),
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _detail(run: str, jobs: list) -> dict:
|
|
145
|
+
views = [_job_view(job) for job in jobs]
|
|
146
|
+
rounds: dict = {}
|
|
147
|
+
for view in views:
|
|
148
|
+
rounds.setdefault(view["round"], []).append(view)
|
|
149
|
+
ordered = sorted(rounds, key=lambda number: (number is None, number or 0))
|
|
150
|
+
checkpoints = [
|
|
151
|
+
{**view["checkpoint"], "round": view["round"], "train_job_id": view["train_job_id"],
|
|
152
|
+
"created_at": view["created_at"]}
|
|
153
|
+
for view in views if view["checkpoint"] is not None
|
|
154
|
+
]
|
|
155
|
+
return {
|
|
156
|
+
**_summary(run, jobs),
|
|
157
|
+
"rounds": [{"round": number, "jobs": rounds[number]} for number in ordered],
|
|
158
|
+
"checkpoints": checkpoints,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class AgentRuns:
|
|
163
|
+
"""Read-only agent runs derived from the visible Train Jobs listing."""
|
|
164
|
+
|
|
165
|
+
def __init__(self, train_jobs: TrainJobs) -> None:
|
|
166
|
+
self._train_jobs = train_jobs
|
|
167
|
+
|
|
168
|
+
def _runs(self):
|
|
169
|
+
jobs, ids, seen, cursor, observed_at = [], set(), set(), None, None
|
|
170
|
+
while True:
|
|
171
|
+
page = self._train_jobs.list(cursor=cursor, limit=PAGE_LIMIT)
|
|
172
|
+
if observed_at is None:
|
|
173
|
+
observed_at = page.get("observed_at")
|
|
174
|
+
for job in page.get("items") or []:
|
|
175
|
+
job_id = job.get("train_job_id") if isinstance(job, dict) else None
|
|
176
|
+
if not isinstance(job, dict) or job_id in ids:
|
|
177
|
+
continue
|
|
178
|
+
if job_id is not None:
|
|
179
|
+
ids.add(job_id)
|
|
180
|
+
jobs.append(job)
|
|
181
|
+
cursor = page.get("next_cursor")
|
|
182
|
+
if cursor is None:
|
|
183
|
+
break
|
|
184
|
+
if cursor in seen:
|
|
185
|
+
raise AgentRunError("TRAIN_JOB_CURSOR_REPEATED")
|
|
186
|
+
seen.add(cursor)
|
|
187
|
+
runs: dict = {}
|
|
188
|
+
for job in jobs:
|
|
189
|
+
key = run_key(job)
|
|
190
|
+
if key is not None:
|
|
191
|
+
runs.setdefault(key, []).append(job)
|
|
192
|
+
for group in runs.values():
|
|
193
|
+
group.sort(key=_order)
|
|
194
|
+
return runs, observed_at
|
|
195
|
+
|
|
196
|
+
def list(self) -> dict:
|
|
197
|
+
"""Every run, most recently updated first, as ``{"items": [...], "observed_at"}``."""
|
|
198
|
+
runs, observed_at = self._runs()
|
|
199
|
+
items = sorted((_summary(run, jobs) for run, jobs in runs.items()),
|
|
200
|
+
key=lambda item: (item["updated_at"] or "", item["run"]), reverse=True)
|
|
201
|
+
return {"items": items, "observed_at": observed_at}
|
|
202
|
+
|
|
203
|
+
def get(self, run: str) -> dict:
|
|
204
|
+
"""One run's summary, its rounds in order with their Train Jobs, and its checkpoints."""
|
|
205
|
+
runs, observed_at = self._runs()
|
|
206
|
+
if run not in runs:
|
|
207
|
+
raise AgentRunError("AGENT_RUN_NOT_FOUND")
|
|
208
|
+
return {**_detail(run, runs[run]), "observed_at": observed_at}
|
envloop/auth_cli.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Authentication configuration and browser login commands."""
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from .config import ConfigError, ConfigStore, normalize_url, validate_key
|
|
8
|
+
|
|
9
|
+
DEFAULT_BASE_URL = "https://api.envloop.ai"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def add_auth_commands(groups):
|
|
13
|
+
auth = groups.add_parser("auth", help="Configure credentials, inspect status, or remove a saved key")
|
|
14
|
+
commands = auth.add_subparsers(dest="auth_command", required=True)
|
|
15
|
+
login = commands.add_parser("login", help="Open the Clerk sign-in page and authorize CLI/SDK access")
|
|
16
|
+
login.add_argument("--base-url", help="API Base URL; defaults to the effective connection or official API")
|
|
17
|
+
login.add_argument("--no-browser", action="store_true", help="Print the login link without opening a browser")
|
|
18
|
+
login.add_argument("--timeout", type=int, choices=range(1, 601), metavar="SECONDS", default=300,
|
|
19
|
+
help="Wait up to 1–600 seconds (default: 300)")
|
|
20
|
+
configure = commands.add_parser("config", help="Save Base URL and API key for CLI and SDK")
|
|
21
|
+
configure.add_argument("--no-check", action="store_true", help="Save without API verification (offline; key remains unverified)")
|
|
22
|
+
actions = configure.add_subparsers(dest="config_action")
|
|
23
|
+
init = actions.add_parser("init", help="Configure interactively with masked API key input")
|
|
24
|
+
init.add_argument("--no-check", action="store_true", default=argparse.SUPPRESS, help="Save without API verification (offline)")
|
|
25
|
+
project = actions.add_parser("project", help="Select and verify a project with its own API key")
|
|
26
|
+
project.add_argument("project_id", nargs="?", help="Project ID; omit for an interactive list with names and IDs")
|
|
27
|
+
project.add_argument("--stdin", action="store_true", help="Read the selected project's API key from stdin")
|
|
28
|
+
setting = actions.add_parser("set", help="Save one field (API key is masked or read from stdin)")
|
|
29
|
+
setting.add_argument("field", choices=["base-url", "api-key", "workspace-id", "project-id"])
|
|
30
|
+
setting.add_argument("value", nargs="?", help=f"Base URL (default: {DEFAULT_BASE_URL}) or workspace ID; API keys use masked input or --stdin")
|
|
31
|
+
setting.add_argument("--stdin", action="store_true", help="Read a single API key from stdin")
|
|
32
|
+
setting.add_argument("--no-check", action="store_true", default=argparse.SUPPRESS, help="Save API key without verification (offline)")
|
|
33
|
+
getting = actions.add_parser("get", help="Read saved configuration; never export an API key")
|
|
34
|
+
getting.add_argument("field", choices=["base-url", "api-key", "workspace-id", "project-id"])
|
|
35
|
+
actions.add_parser("path", help="Print configuration and credential file paths")
|
|
36
|
+
show = actions.add_parser("show", help="Show saved and effective settings without revealing keys")
|
|
37
|
+
show.add_argument("--json", action="store_true")
|
|
38
|
+
remove = actions.add_parser("unset", help="Delete a saved field; does not revoke the server key")
|
|
39
|
+
remove.add_argument("field", choices=["base-url", "api-key", "workspace-id", "project-id"])
|
|
40
|
+
status = commands.add_parser("status", help="Inspect local credentials; --check verifies read access")
|
|
41
|
+
status.add_argument("--check", action="store_true", help="Make a read-only request to the effective Base URL")
|
|
42
|
+
status.add_argument("--json", action="store_true")
|
|
43
|
+
commands.add_parser("logout", help="Remove the saved key for the stored default Base URL (local only)")
|
|
44
|
+
workspace = commands.add_parser("workspace", help="Select and verify the active workspace")
|
|
45
|
+
workspace.add_argument("workspace_id", nargs="?", help="Workspace ID; omit for an interactive list")
|
|
46
|
+
workspace.add_argument("--project-id", help="Project within the workspace; its own API key is required")
|
|
47
|
+
workspace.add_argument("--stdin", action="store_true", help="Read the selected workspace's API key from stdin")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _key_from_input(stdin=False):
|
|
51
|
+
if stdin:
|
|
52
|
+
value = sys.stdin.read(515)
|
|
53
|
+
if value.endswith("\r\n"):
|
|
54
|
+
value = value[:-2]
|
|
55
|
+
elif value.endswith("\n"):
|
|
56
|
+
value = value[:-1]
|
|
57
|
+
return value
|
|
58
|
+
if not sys.stdin.isatty():
|
|
59
|
+
raise ConfigError("AUTH_INTERACTIVE_REQUIRED")
|
|
60
|
+
return _masked_key()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _masked_key():
|
|
64
|
+
if os.name != "posix":
|
|
65
|
+
raise ConfigError("CONFIG_STORAGE_UNSUPPORTED")
|
|
66
|
+
import termios
|
|
67
|
+
|
|
68
|
+
descriptor = sys.stdin.fileno()
|
|
69
|
+
original = termios.tcgetattr(descriptor)
|
|
70
|
+
masked = original[:]
|
|
71
|
+
masked[6] = original[6][:]
|
|
72
|
+
masked[3] &= ~(termios.ECHO | termios.ICANON | termios.ISIG | termios.IEXTEN)
|
|
73
|
+
masked[6][termios.VMIN] = 1
|
|
74
|
+
masked[6][termios.VTIME] = 0
|
|
75
|
+
characters = []
|
|
76
|
+
try:
|
|
77
|
+
termios.tcsetattr(descriptor, termios.TCSAFLUSH, masked)
|
|
78
|
+
sys.stderr.write("API key: ")
|
|
79
|
+
sys.stderr.flush()
|
|
80
|
+
while True:
|
|
81
|
+
character = sys.stdin.read(1)
|
|
82
|
+
if character in {"", "\x04"}:
|
|
83
|
+
raise EOFError
|
|
84
|
+
if character == "\x03":
|
|
85
|
+
raise KeyboardInterrupt
|
|
86
|
+
if character in {"\r", "\n"}:
|
|
87
|
+
return "".join(characters)
|
|
88
|
+
if character in {"\x08", "\x7f"}:
|
|
89
|
+
if characters:
|
|
90
|
+
characters.pop()
|
|
91
|
+
sys.stderr.write("\b \b")
|
|
92
|
+
elif character == "\x15":
|
|
93
|
+
sys.stderr.write("\b \b" * len(characters))
|
|
94
|
+
characters.clear()
|
|
95
|
+
else:
|
|
96
|
+
characters.append(character)
|
|
97
|
+
sys.stderr.write("*")
|
|
98
|
+
sys.stderr.flush()
|
|
99
|
+
finally:
|
|
100
|
+
termios.tcsetattr(descriptor, termios.TCSAFLUSH, original)
|
|
101
|
+
sys.stderr.write("\n")
|
|
102
|
+
sys.stderr.flush()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _report(args, store):
|
|
106
|
+
saved, configured = store.inspect()
|
|
107
|
+
from .cli import resolve_cli_connection
|
|
108
|
+
|
|
109
|
+
connection = resolve_cli_connection()
|
|
110
|
+
return {
|
|
111
|
+
"saved": {**saved, "api_key": "configured" if configured else "missing"},
|
|
112
|
+
"effective": {"api_url": connection.api_url, "workspace_id": connection.workspace_id, "project_id": connection.project_id,
|
|
113
|
+
"api_key": "configured" if connection.api_key is not None else "missing"},
|
|
114
|
+
"sources": connection.sources,
|
|
115
|
+
"check": "not_checked",
|
|
116
|
+
}, connection
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _verify_key(url, key, workspace_id=None, project_id=None):
|
|
120
|
+
from .http import HttpService, RemoteError
|
|
121
|
+
|
|
122
|
+
validate_key(key)
|
|
123
|
+
print("Checking API key...", flush=True)
|
|
124
|
+
if workspace_id is not None:
|
|
125
|
+
from .auth_workspace import _identity
|
|
126
|
+
|
|
127
|
+
scope = _identity(url, key, workspace_id, project_id, details=True)
|
|
128
|
+
return scope if scope.get('project_id') is not None else None
|
|
129
|
+
try:
|
|
130
|
+
response = HttpService(url, timeout=5, api_key=key, project_id=project_id, config=False,
|
|
131
|
+
environment=False).request("GET", "/v1/me")
|
|
132
|
+
except RemoteError as error:
|
|
133
|
+
code = {401: "AUTHENTICATION_REJECTED", 403: "AUTHORIZATION_DENIED"}.get(error.status_code)
|
|
134
|
+
if code is None:
|
|
135
|
+
code = error.code if error.status_code is None and error.code in {
|
|
136
|
+
"NETWORK_ERROR", "INVALID_RESPONSE", "RESPONSE_TOO_LARGE",
|
|
137
|
+
} else "HTTP_ERROR"
|
|
138
|
+
raise ConfigError(code) from None
|
|
139
|
+
if not isinstance(response, dict) or response.get("auth_method") != "api_key":
|
|
140
|
+
raise ConfigError("AUTH_CHECK_UNCONFIRMED")
|
|
141
|
+
if response.get('project') is not None or project_id is not None:
|
|
142
|
+
from .auth_workspace import _scope
|
|
143
|
+
|
|
144
|
+
return _scope(response, project=project_id)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _print(report, as_json=False):
|
|
148
|
+
if as_json:
|
|
149
|
+
print(json.dumps(report, ensure_ascii=False))
|
|
150
|
+
return
|
|
151
|
+
print(f"Base URL: {report['effective']['api_url'] or 'not configured'}")
|
|
152
|
+
print(f"API key: {report['effective']['api_key']} (source: {report['sources']['api_key'] or 'none'})")
|
|
153
|
+
print(f"Workspace: {report['effective']['workspace_id'] or 'server default'}")
|
|
154
|
+
print(f"Project: {report['effective']['project_id'] or 'key scope'}")
|
|
155
|
+
print(f"Saved Base URL: {report['saved'].get('api_url') or 'not configured'}")
|
|
156
|
+
print(f"Saved API key: {report['saved']['api_key']}")
|
|
157
|
+
print(f"Saved workspace: {report['saved'].get('workspace_id') or 'server default'}")
|
|
158
|
+
print(f"Saved project: {report['saved'].get('project_id') or 'key scope'}")
|
|
159
|
+
print(f"Check: {report['check']}")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def run_auth(args, parser):
|
|
163
|
+
store = ConfigStore()
|
|
164
|
+
command = args.auth_command
|
|
165
|
+
if command == "login":
|
|
166
|
+
from .auth_login import login
|
|
167
|
+
|
|
168
|
+
return login(args, store)
|
|
169
|
+
if command == "workspace":
|
|
170
|
+
from .auth_workspace import configure_workspace
|
|
171
|
+
|
|
172
|
+
return configure_workspace(args, store)
|
|
173
|
+
action = getattr(args, "config_action", None)
|
|
174
|
+
no_check = getattr(args, "no_check", False)
|
|
175
|
+
if getattr(args, "field", None) == "base-url":
|
|
176
|
+
args.field = "api-url"
|
|
177
|
+
if no_check and not (action in {None, "init"} or action == "set" and args.field == "api-key"):
|
|
178
|
+
parser.error("--no-check applies only to the wizard or set api-key")
|
|
179
|
+
if command == "config":
|
|
180
|
+
if action == "project":
|
|
181
|
+
from .auth_workspace import configure_project
|
|
182
|
+
|
|
183
|
+
return configure_project(args, store)
|
|
184
|
+
if action == "path":
|
|
185
|
+
print(json.dumps({"home": str(store.path), "config": str(store.path / "config.toml"), "auth": str(store.path / "auth.json")}))
|
|
186
|
+
return 0
|
|
187
|
+
if action == "set":
|
|
188
|
+
scope = None
|
|
189
|
+
if args.field == "api-key":
|
|
190
|
+
if args.value is not None:
|
|
191
|
+
parser.error("API key values must be entered interactively or through --stdin")
|
|
192
|
+
value = _key_from_input(args.stdin)
|
|
193
|
+
validate_key(value)
|
|
194
|
+
saved, credential = store.workspace_snapshot()
|
|
195
|
+
url = saved.get("api_url")
|
|
196
|
+
if url is None:
|
|
197
|
+
raise ConfigError("API_URL_REQUIRED")
|
|
198
|
+
if not no_check:
|
|
199
|
+
scope = _verify_key(url, value, saved.get("workspace_id"), saved.get("project_id"))
|
|
200
|
+
elif args.field == "api-url":
|
|
201
|
+
if args.stdin:
|
|
202
|
+
parser.error("this field does not support --stdin")
|
|
203
|
+
value = args.value if args.value is not None else DEFAULT_BASE_URL
|
|
204
|
+
else:
|
|
205
|
+
if args.value is None or args.stdin:
|
|
206
|
+
parser.error("this field requires a value and does not support --stdin")
|
|
207
|
+
value = args.value
|
|
208
|
+
if args.field == "api-key":
|
|
209
|
+
if scope is not None:
|
|
210
|
+
store.configure(url, value, expected_saved=saved, expected_credential=credential, scope=scope)
|
|
211
|
+
else:
|
|
212
|
+
store.update(args.field, value, expected_url=url, expected_saved=saved, expected_credential=credential)
|
|
213
|
+
else:
|
|
214
|
+
store.update(args.field, value)
|
|
215
|
+
if args.field == "api-key":
|
|
216
|
+
print("Saved. API key check: " + ("not_checked (offline)" if no_check else "authentication_confirmed"))
|
|
217
|
+
else:
|
|
218
|
+
print("Saved.")
|
|
219
|
+
print("Environment variables override saved configuration.")
|
|
220
|
+
return 0
|
|
221
|
+
if action == "unset":
|
|
222
|
+
store.update(args.field, remove=True)
|
|
223
|
+
print("Saved value removed locally. Server keys are not revoked; environment variables still apply.")
|
|
224
|
+
if "ENVLOOP_API_KEY" in os.environ:
|
|
225
|
+
print("ENVLOOP_API_KEY still overrides saved credentials.")
|
|
226
|
+
return 0
|
|
227
|
+
if action == "get":
|
|
228
|
+
saved, configured = store.inspect()
|
|
229
|
+
value = ("configured" if configured else "missing") if args.field == "api-key" else saved.get(args.field.replace("-", "_"))
|
|
230
|
+
if value is not None:
|
|
231
|
+
print(value)
|
|
232
|
+
return 0
|
|
233
|
+
if action is None or action == "init":
|
|
234
|
+
if not sys.stdin.isatty():
|
|
235
|
+
raise ConfigError("AUTH_INTERACTIVE_REQUIRED")
|
|
236
|
+
saved, credential = store.workspace_snapshot(DEFAULT_BASE_URL)
|
|
237
|
+
default = saved.get("api_url", DEFAULT_BASE_URL)
|
|
238
|
+
url = normalize_url(default)
|
|
239
|
+
print(f"Base URL: {url}")
|
|
240
|
+
key = _key_from_input()
|
|
241
|
+
key = key or store.credential(url, saved.get("workspace_id"), saved.get("project_id"))
|
|
242
|
+
if key is None:
|
|
243
|
+
raise ConfigError("API_KEY_REQUIRED")
|
|
244
|
+
store.inspect()
|
|
245
|
+
scope = None if no_check else _verify_key(url, key, saved.get("workspace_id"), saved.get("project_id"))
|
|
246
|
+
remembered = None
|
|
247
|
+
if scope is not None:
|
|
248
|
+
from .auth_workspace import select_project
|
|
249
|
+
|
|
250
|
+
remembered = (scope["workspace_id"], scope["project_id"], key)
|
|
251
|
+
key, scope = select_project(url, key, scope, credential.get("project_keys", {}))
|
|
252
|
+
store.configure(url, key, expected_saved=saved, expected_credential=credential,
|
|
253
|
+
scope=scope, remembered=remembered)
|
|
254
|
+
print(f"Saved configuration in {store.path}. Environment variables override saved configuration.")
|
|
255
|
+
print("Saved key check: " + ("not_checked (offline)" if no_check else "authentication_confirmed"))
|
|
256
|
+
report, connection = _report(args, store)
|
|
257
|
+
if not no_check and connection.api_url == url and connection.api_key == key:
|
|
258
|
+
report["check"] = "authentication_confirmed"
|
|
259
|
+
_print(report)
|
|
260
|
+
return 0
|
|
261
|
+
if command == "logout":
|
|
262
|
+
store.update("api-key", remove=True)
|
|
263
|
+
print("Saved default API key removed locally. The server key has not been revoked.")
|
|
264
|
+
if "ENVLOOP_API_KEY" in os.environ:
|
|
265
|
+
print("ENVLOOP_API_KEY remains active; unset it separately.")
|
|
266
|
+
return 0
|
|
267
|
+
report, connection = _report(args, store)
|
|
268
|
+
if command == "status":
|
|
269
|
+
if args.check:
|
|
270
|
+
from .config import require_remote
|
|
271
|
+
from .http import HttpService, RemoteError
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
require_remote(connection)
|
|
275
|
+
HttpService(connection.api_url, api_key=connection.api_key,
|
|
276
|
+
workspace_id=connection.workspace_id, project_id=connection.project_id,
|
|
277
|
+
config=False, environment=False).list_jobs(limit=1)
|
|
278
|
+
report["check"] = "access_confirmed" if connection.api_key else "reachable_anonymous"
|
|
279
|
+
except (ConfigError, RemoteError) as error:
|
|
280
|
+
report["check"] = {401: "authentication_rejected", 403: "authorization_denied"}.get(
|
|
281
|
+
getattr(error, "status_code", None), error.code)
|
|
282
|
+
_print(report, args.json)
|
|
283
|
+
return 1
|
|
284
|
+
_print(report, getattr(args, "json", False))
|
|
285
|
+
return 0
|
envloop/auth_login.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Browser authorization using a CLI-owned key and bounded anonymous polling."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import secrets
|
|
5
|
+
import time
|
|
6
|
+
import webbrowser
|
|
7
|
+
from http.client import InvalidURL
|
|
8
|
+
from urllib.error import HTTPError, URLError
|
|
9
|
+
from urllib.parse import parse_qs, urlsplit
|
|
10
|
+
from urllib.request import ProxyHandler, Request, build_opener
|
|
11
|
+
|
|
12
|
+
from .auth_workspace import _identity
|
|
13
|
+
from .config import ConfigError, normalize_url
|
|
14
|
+
from .http import NoRedirect
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _request(url, action, body, timeout):
|
|
18
|
+
request = Request(url + '/v1/auth/cli/' + action, data=json.dumps(body).encode(),
|
|
19
|
+
headers={'Content-Type': 'application/json'}, method='POST')
|
|
20
|
+
try:
|
|
21
|
+
with build_opener(ProxyHandler(), NoRedirect()).open(request, timeout=timeout) as response:
|
|
22
|
+
raw = response.read(8193)
|
|
23
|
+
if len(raw) > 8192:
|
|
24
|
+
raise ConfigError('AUTH_LOGIN_INVALID_RESPONSE')
|
|
25
|
+
result = json.loads(raw)
|
|
26
|
+
if not isinstance(result, dict):
|
|
27
|
+
raise ConfigError('AUTH_LOGIN_INVALID_RESPONSE')
|
|
28
|
+
return result
|
|
29
|
+
except HTTPError as error:
|
|
30
|
+
code = {403: 'AUTHORIZATION_DENIED', 409: 'AUTH_LOGIN_UNAVAILABLE', 410: 'AUTH_LOGIN_EXPIRED',
|
|
31
|
+
429: 'AUTH_LOGIN_BUSY', 503: 'AUTH_LOGIN_UNAVAILABLE'}.get(error.code, 'AUTH_LOGIN_HTTP_ERROR')
|
|
32
|
+
raise ConfigError(code) from None
|
|
33
|
+
except (URLError, TimeoutError, OSError, InvalidURL):
|
|
34
|
+
raise ConfigError('NETWORK_ERROR') from None
|
|
35
|
+
except (ValueError, UnicodeError):
|
|
36
|
+
raise ConfigError('AUTH_LOGIN_INVALID_RESPONSE') from None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _verification_url(url, response):
|
|
40
|
+
import re
|
|
41
|
+
|
|
42
|
+
identifier = response.get('grant_id')
|
|
43
|
+
uri = response.get('verification_uri')
|
|
44
|
+
if not isinstance(identifier, str) or not re.fullmatch(r'[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}', identifier):
|
|
45
|
+
raise ConfigError('AUTH_LOGIN_INVALID_RESPONSE')
|
|
46
|
+
if not isinstance(uri, str):
|
|
47
|
+
raise ConfigError('AUTH_LOGIN_INVALID_RESPONSE')
|
|
48
|
+
try:
|
|
49
|
+
page, api = urlsplit(uri), urlsplit(url)
|
|
50
|
+
normalized = normalize_url(f'{page.scheme}://{page.netloc}')
|
|
51
|
+
expected = 'https://console.envloop.ai' if url == 'https://api.envloop.ai' else f'{api.scheme}://{api.netloc}'
|
|
52
|
+
if (f'{page.scheme}://{page.netloc}' != expected or page.path != '/sign-in' or page.fragment or
|
|
53
|
+
parse_qs(page.query, strict_parsing=True) != {'cli_login': [identifier]} or
|
|
54
|
+
normalized != expected):
|
|
55
|
+
raise ConfigError('AUTH_LOGIN_INVALID_RESPONSE')
|
|
56
|
+
except (ValueError, ConfigError):
|
|
57
|
+
raise ConfigError('AUTH_LOGIN_INVALID_RESPONSE') from None
|
|
58
|
+
return identifier, uri
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def login(args, store):
|
|
62
|
+
from .auth_cli import DEFAULT_BASE_URL
|
|
63
|
+
from .cli import resolve_cli_connection
|
|
64
|
+
|
|
65
|
+
saved, credential = store.workspace_snapshot()
|
|
66
|
+
url = normalize_url(args.base_url or resolve_cli_connection().api_url or DEFAULT_BASE_URL)
|
|
67
|
+
deadline = time.monotonic() + args.timeout
|
|
68
|
+
key, poll_token = 'epk_' + secrets.token_hex(32), secrets.token_urlsafe(32)
|
|
69
|
+
code = ''.join(secrets.choice('ABCDEFGHJKLMNPQRSTUVWXYZ23456789') for _ in range(8))
|
|
70
|
+
def digest(value):
|
|
71
|
+
return hashlib.sha256(value.encode()).hexdigest()
|
|
72
|
+
response = _request(url, 'start', {'key_hash': digest(key), 'poll_hash': digest(poll_token),
|
|
73
|
+
'code_hash': digest(code)}, min(5, args.timeout))
|
|
74
|
+
identifier, uri = _verification_url(url, response)
|
|
75
|
+
interval, expires = response.get('interval'), response.get('expires_in')
|
|
76
|
+
if (isinstance(interval, bool) or not isinstance(interval, (int, float)) or not 0 < interval <= 10 or
|
|
77
|
+
isinstance(expires, bool) or not isinstance(expires, int) or not 1 <= expires <= 600):
|
|
78
|
+
raise ConfigError('AUTH_LOGIN_INVALID_RESPONSE')
|
|
79
|
+
deadline = min(deadline, time.monotonic() + expires)
|
|
80
|
+
body = {'grant_id': identifier, 'poll_token': poll_token}
|
|
81
|
+
saved_successfully = False
|
|
82
|
+
try:
|
|
83
|
+
print(f'Base URL: {url}\nConfirmation code: {code}\nOpen: {uri}', flush=True)
|
|
84
|
+
print('Sign in with Clerk, enter this code, and authorize your workspace/project.', flush=True)
|
|
85
|
+
if not args.no_browser:
|
|
86
|
+
try:
|
|
87
|
+
opened = webbrowser.open(uri, new=1)
|
|
88
|
+
except webbrowser.Error:
|
|
89
|
+
opened = False
|
|
90
|
+
if not opened:
|
|
91
|
+
print('Browser could not be opened. Copy the link above into your browser.', flush=True)
|
|
92
|
+
while True:
|
|
93
|
+
remaining = deadline - time.monotonic()
|
|
94
|
+
if remaining <= 0:
|
|
95
|
+
raise ConfigError('AUTH_LOGIN_TIMEOUT')
|
|
96
|
+
try:
|
|
97
|
+
result = _request(url, 'poll', body, min(5, remaining))
|
|
98
|
+
except ConfigError as error:
|
|
99
|
+
if error.code != 'NETWORK_ERROR':
|
|
100
|
+
raise
|
|
101
|
+
# The server returns the same non-secret receipt after a lost
|
|
102
|
+
# response, while inserting the credential only once.
|
|
103
|
+
time.sleep(min(interval, max(0, deadline - time.monotonic())))
|
|
104
|
+
continue
|
|
105
|
+
if result.get('status') == 'approved':
|
|
106
|
+
# Verify the exact scope before committing the saved connection.
|
|
107
|
+
scope = _identity(url, key, result.get('workspace_id'), result.get('project_id'), details=True)
|
|
108
|
+
if (not result.get('workspace_id') or not result.get('project_id') or
|
|
109
|
+
scope.get('project_id') != result['project_id']):
|
|
110
|
+
raise ConfigError('AUTH_LOGIN_INVALID_RESPONSE')
|
|
111
|
+
store.configure(url, key, expected_saved=saved,
|
|
112
|
+
expected_credential=credential if saved.get('api_url') == url else None, scope=scope)
|
|
113
|
+
saved_successfully = True
|
|
114
|
+
print('Signed in. Verified project credentials saved for CLI and SDK (expires in 30 days).')
|
|
115
|
+
print('Environment variables override saved configuration. auth logout removes local credentials only.')
|
|
116
|
+
return 0
|
|
117
|
+
if result.get('status') in {'denied', 'cancelled'}:
|
|
118
|
+
raise ConfigError('AUTH_LOGIN_DENIED')
|
|
119
|
+
if result.get('status') != 'pending':
|
|
120
|
+
raise ConfigError('AUTH_LOGIN_INVALID_RESPONSE')
|
|
121
|
+
time.sleep(min(interval, max(0, deadline - time.monotonic())))
|
|
122
|
+
finally:
|
|
123
|
+
if not saved_successfully:
|
|
124
|
+
try:
|
|
125
|
+
_request(url, 'cancel', body, 2)
|
|
126
|
+
except ConfigError:
|
|
127
|
+
pass
|