softmax-cli 0.26.16__tar.gz → 0.26.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.
- {softmax_cli-0.26.16/src/softmax_cli.egg-info → softmax_cli-0.26.18}/PKG-INFO +1 -1
- {softmax_cli-0.26.16 → softmax_cli-0.26.18}/src/softmax/auth.py +81 -0
- {softmax_cli-0.26.16 → softmax_cli-0.26.18}/src/softmax/cli.py +10 -10
- {softmax_cli-0.26.16 → softmax_cli-0.26.18}/src/softmax/perform_login.py +16 -13
- {softmax_cli-0.26.16 → softmax_cli-0.26.18/src/softmax_cli.egg-info}/PKG-INFO +1 -1
- {softmax_cli-0.26.16 → softmax_cli-0.26.18}/AGENTS.md +0 -0
- {softmax_cli-0.26.16 → softmax_cli-0.26.18}/BUILD.bazel +0 -0
- {softmax_cli-0.26.16 → softmax_cli-0.26.18}/CLAUDE.md +0 -0
- {softmax_cli-0.26.16 → softmax_cli-0.26.18}/README.md +0 -0
- {softmax_cli-0.26.16 → softmax_cli-0.26.18}/pyproject.toml +0 -0
- {softmax_cli-0.26.16 → softmax_cli-0.26.18}/setup.cfg +0 -0
- {softmax_cli-0.26.16 → softmax_cli-0.26.18}/src/softmax/__init__.py +0 -0
- {softmax_cli-0.26.16 → softmax_cli-0.26.18}/src/softmax/_console.py +0 -0
- {softmax_cli-0.26.16 → softmax_cli-0.26.18}/src/softmax/cogames.py +0 -0
- {softmax_cli-0.26.16 → softmax_cli-0.26.18}/src/softmax_cli.egg-info/SOURCES.txt +0 -0
- {softmax_cli-0.26.16 → softmax_cli-0.26.18}/src/softmax_cli.egg-info/dependency_links.txt +0 -0
- {softmax_cli-0.26.16 → softmax_cli-0.26.18}/src/softmax_cli.egg-info/entry_points.txt +0 -0
- {softmax_cli-0.26.16 → softmax_cli-0.26.18}/src/softmax_cli.egg-info/requires.txt +0 -0
- {softmax_cli-0.26.16 → softmax_cli-0.26.18}/src/softmax_cli.egg-info/top_level.txt +0 -0
- {softmax_cli-0.26.16 → softmax_cli-0.26.18}/tests/BUILD.bazel +0 -0
- {softmax_cli-0.26.16 → softmax_cli-0.26.18}/tests/test_auth_login.py +0 -0
- {softmax_cli-0.26.16 → softmax_cli-0.26.18}/tests/test_python_api.py +0 -0
|
@@ -14,11 +14,32 @@ DEFAULT_API_SERVER = "https://softmax.com/api"
|
|
|
14
14
|
|
|
15
15
|
CREDENTIALS_FILE = "credentials.yaml"
|
|
16
16
|
|
|
17
|
+
# Hosts that exchange_auth_code is willing to send a freshly minted auth code
|
|
18
|
+
# to. The exchange step is the only place a real credential leaves the box, so
|
|
19
|
+
# this is the trust boundary for the login flow. Other commands (status,
|
|
20
|
+
# get-login-url, set-token, ...) are unrestricted so local dev against a custom
|
|
21
|
+
# backend still works.
|
|
22
|
+
_TRUSTED_HOST_SUFFIXES = (".softmax.com", ".softmax-research.net")
|
|
23
|
+
_TRUSTED_EXACT_HOSTS = frozenset({"softmax.com", "localhost", "127.0.0.1", "::1"})
|
|
24
|
+
|
|
17
25
|
|
|
18
26
|
def get_api_server() -> str:
|
|
19
27
|
return os.environ.get("COGAMES_API_URL", DEFAULT_API_SERVER)
|
|
20
28
|
|
|
21
29
|
|
|
30
|
+
class UntrustedApiServerError(Exception):
|
|
31
|
+
"""Raised when exchange_auth_code is called against an untrusted host."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _is_trusted_exchange_host(api_server: str) -> bool:
|
|
35
|
+
host = (urlsplit(api_server).hostname or "").lower()
|
|
36
|
+
if not host:
|
|
37
|
+
return False
|
|
38
|
+
if host in _TRUSTED_EXACT_HOSTS:
|
|
39
|
+
return True
|
|
40
|
+
return any(host.endswith(suffix) for suffix in _TRUSTED_HOST_SUFFIXES)
|
|
41
|
+
|
|
42
|
+
|
|
22
43
|
# --- on-disk storage ---
|
|
23
44
|
|
|
24
45
|
|
|
@@ -157,6 +178,13 @@ def fetch_cogames_whoami(*, api_server: str | None = None, token: str) -> WhoAmI
|
|
|
157
178
|
|
|
158
179
|
def exchange_auth_code(*, api_server: str, code: str) -> str:
|
|
159
180
|
"""Exchange a one-time auth code for a real credential token."""
|
|
181
|
+
if not _is_trusted_exchange_host(api_server):
|
|
182
|
+
host = urlsplit(api_server).hostname or api_server
|
|
183
|
+
raise UntrustedApiServerError(
|
|
184
|
+
f"Refusing to send auth code to untrusted host '{host}'. "
|
|
185
|
+
"Auth codes are only exchanged with softmax.com, *.softmax.com, "
|
|
186
|
+
"*.softmax-research.net, or loopback (localhost / 127.0.0.1)."
|
|
187
|
+
)
|
|
160
188
|
response = httpx.post(
|
|
161
189
|
f"{api_server.rstrip('/')}/observatory/credentials/auth-codes/exchange",
|
|
162
190
|
json={"code": code},
|
|
@@ -164,3 +192,56 @@ def exchange_auth_code(*, api_server: str, code: str) -> str:
|
|
|
164
192
|
)
|
|
165
193
|
response.raise_for_status()
|
|
166
194
|
return response.json()["token"]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _format_status_error(exc: httpx.HTTPStatusError) -> str:
|
|
198
|
+
status = exc.response.status_code
|
|
199
|
+
if status // 100 == 3:
|
|
200
|
+
return (
|
|
201
|
+
"Failed to exchange auth code due to a redirect. "
|
|
202
|
+
"You may have accidentally pointed --server to the frontend application URL.\n"
|
|
203
|
+
"If you have a valid code, you can still run the following command to exchange it for a token:\n\n"
|
|
204
|
+
"softmax exchange-code <code>"
|
|
205
|
+
)
|
|
206
|
+
if status == 410:
|
|
207
|
+
return (
|
|
208
|
+
"This auth code has already been used or expired. "
|
|
209
|
+
"If you just ran `softmax login`, your token was saved automatically — run `softmax status` to verify."
|
|
210
|
+
)
|
|
211
|
+
if status == 400:
|
|
212
|
+
return "Invalid auth code format."
|
|
213
|
+
return f"Code exchange failed (HTTP {status}): {exc.response.text}"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class ExchangeSuccess(BaseModel):
|
|
217
|
+
token: str
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class ExchangeFailure(BaseModel):
|
|
221
|
+
message: str
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
ExchangeOutcome = ExchangeSuccess | ExchangeFailure
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def try_exchange_auth_code(*, api_server: str, code: str) -> ExchangeOutcome:
|
|
228
|
+
"""Exchange an auth code, returning a typed success/failure result.
|
|
229
|
+
|
|
230
|
+
Centralizes the error-classification ladder so every callsite (CLI command,
|
|
231
|
+
manual-paste loop, callback handler) gets the same message without repeating
|
|
232
|
+
try/except blocks.
|
|
233
|
+
"""
|
|
234
|
+
try:
|
|
235
|
+
token = exchange_auth_code(api_server=api_server, code=code)
|
|
236
|
+
except UntrustedApiServerError as exc:
|
|
237
|
+
return ExchangeFailure(message=str(exc))
|
|
238
|
+
except httpx.HTTPStatusError as exc:
|
|
239
|
+
return ExchangeFailure(message=_format_status_error(exc))
|
|
240
|
+
except httpx.HTTPError:
|
|
241
|
+
return ExchangeFailure(
|
|
242
|
+
message=(
|
|
243
|
+
"Could not exchange auth code. Check that --server points at the API "
|
|
244
|
+
"(e.g. https://softmax.com/api) and try again."
|
|
245
|
+
),
|
|
246
|
+
)
|
|
247
|
+
return ExchangeSuccess(token=token)
|
|
@@ -9,14 +9,15 @@ from rich.panel import Panel
|
|
|
9
9
|
from softmax._console import console
|
|
10
10
|
from softmax.auth import (
|
|
11
11
|
DEFAULT_API_SERVER,
|
|
12
|
+
ExchangeFailure,
|
|
12
13
|
build_browser_login_url,
|
|
13
14
|
delete_all_tokens,
|
|
14
|
-
exchange_auth_code,
|
|
15
15
|
fetch_cogames_whoami,
|
|
16
16
|
get_api_server,
|
|
17
17
|
load_current_token,
|
|
18
18
|
load_user_token,
|
|
19
19
|
save_user_token,
|
|
20
|
+
try_exchange_auth_code,
|
|
20
21
|
)
|
|
21
22
|
from softmax.perform_login import do_interactive_login_for_token
|
|
22
23
|
|
|
@@ -110,16 +111,12 @@ def login_cmd(
|
|
|
110
111
|
try:
|
|
111
112
|
do_interactive_login_for_token(
|
|
112
113
|
api_server=api_server,
|
|
113
|
-
agent_hint=
|
|
114
|
-
"If you are a coding agent, ask your human to open the URL below and give you "
|
|
115
|
-
"the auth code. Then paste the code into this window or run:\n"
|
|
116
|
-
"\n"
|
|
117
|
-
f"{_build_manual_exchange_command(server)}"
|
|
118
|
-
),
|
|
114
|
+
agent_hint=None,
|
|
119
115
|
open_browser=not no_browser,
|
|
120
116
|
)
|
|
121
117
|
except Exception as e:
|
|
122
|
-
console.print(
|
|
118
|
+
console.print()
|
|
119
|
+
console.print(f"Error: {e}", style="red", highlight=False)
|
|
123
120
|
console.print()
|
|
124
121
|
console.print("Authentication failed.", style="red")
|
|
125
122
|
raise typer.Exit(1) from e
|
|
@@ -236,6 +233,9 @@ def exchange_code_cmd(
|
|
|
236
233
|
) -> None:
|
|
237
234
|
"""Exchange a one-time auth code for a credential."""
|
|
238
235
|
api_server = server or get_api_server()
|
|
239
|
-
|
|
240
|
-
|
|
236
|
+
result = try_exchange_auth_code(api_server=api_server, code=code)
|
|
237
|
+
if isinstance(result, ExchangeFailure):
|
|
238
|
+
console.print(result.message, style="red", highlight=False)
|
|
239
|
+
raise typer.Exit(1)
|
|
240
|
+
save_user_token(server=api_server, token=result.token)
|
|
241
241
|
print(f"\nToken saved for {api_server}")
|
|
@@ -20,7 +20,12 @@ from fastapi.responses import HTMLResponse
|
|
|
20
20
|
from rich.panel import Panel
|
|
21
21
|
|
|
22
22
|
from softmax._console import console
|
|
23
|
-
from softmax.auth import
|
|
23
|
+
from softmax.auth import (
|
|
24
|
+
ExchangeFailure,
|
|
25
|
+
build_browser_login_url,
|
|
26
|
+
save_user_token,
|
|
27
|
+
try_exchange_auth_code,
|
|
28
|
+
)
|
|
24
29
|
|
|
25
30
|
|
|
26
31
|
@dataclass
|
|
@@ -257,13 +262,12 @@ def _create_app(session: _CLIAuthSession, *, api_server: str) -> FastAPI:
|
|
|
257
262
|
_finish_authentication(session, error="No code received in callback")
|
|
258
263
|
return HTMLResponse(content=_error_html(error_message="No code received"), status_code=400)
|
|
259
264
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
return HTMLResponse(content=_error_html(error_message=f"Code exchange failed: {exc}"), status_code=500)
|
|
265
|
+
result = try_exchange_auth_code(api_server=api_server, code=code)
|
|
266
|
+
if isinstance(result, ExchangeFailure):
|
|
267
|
+
_finish_authentication(session, error=result.message)
|
|
268
|
+
return HTMLResponse(content=_error_html(error_message=result.message), status_code=500)
|
|
265
269
|
|
|
266
|
-
_finish_authentication(session, token=token)
|
|
270
|
+
_finish_authentication(session, token=result.token)
|
|
267
271
|
return HTMLResponse(content=_success_html())
|
|
268
272
|
|
|
269
273
|
return app
|
|
@@ -298,13 +302,12 @@ def _start_manual_code_prompt(*, session: _CLIAuthSession, api_server: str) -> N
|
|
|
298
302
|
if not code:
|
|
299
303
|
continue
|
|
300
304
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
continue
|
|
305
|
+
result = try_exchange_auth_code(api_server=api_server, code=code)
|
|
306
|
+
if isinstance(result, ExchangeFailure):
|
|
307
|
+
_finish_authentication(session, error=result.message)
|
|
308
|
+
return
|
|
306
309
|
|
|
307
|
-
_finish_authentication(session, token=token)
|
|
310
|
+
_finish_authentication(session, token=result.token)
|
|
308
311
|
return
|
|
309
312
|
|
|
310
313
|
threading.Thread(target=prompt_loop, daemon=True).start()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|