envloop-cli 0.1.1__py3-none-any.whl → 0.2.1__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 ADDED
@@ -0,0 +1,5 @@
1
+ """EnvLoop Python client."""
2
+
3
+ from .client import Client
4
+
5
+ __all__ = ["Client"]
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