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.
Files changed (22) hide show
  1. {softmax_cli-0.26.16/src/softmax_cli.egg-info → softmax_cli-0.26.18}/PKG-INFO +1 -1
  2. {softmax_cli-0.26.16 → softmax_cli-0.26.18}/src/softmax/auth.py +81 -0
  3. {softmax_cli-0.26.16 → softmax_cli-0.26.18}/src/softmax/cli.py +10 -10
  4. {softmax_cli-0.26.16 → softmax_cli-0.26.18}/src/softmax/perform_login.py +16 -13
  5. {softmax_cli-0.26.16 → softmax_cli-0.26.18/src/softmax_cli.egg-info}/PKG-INFO +1 -1
  6. {softmax_cli-0.26.16 → softmax_cli-0.26.18}/AGENTS.md +0 -0
  7. {softmax_cli-0.26.16 → softmax_cli-0.26.18}/BUILD.bazel +0 -0
  8. {softmax_cli-0.26.16 → softmax_cli-0.26.18}/CLAUDE.md +0 -0
  9. {softmax_cli-0.26.16 → softmax_cli-0.26.18}/README.md +0 -0
  10. {softmax_cli-0.26.16 → softmax_cli-0.26.18}/pyproject.toml +0 -0
  11. {softmax_cli-0.26.16 → softmax_cli-0.26.18}/setup.cfg +0 -0
  12. {softmax_cli-0.26.16 → softmax_cli-0.26.18}/src/softmax/__init__.py +0 -0
  13. {softmax_cli-0.26.16 → softmax_cli-0.26.18}/src/softmax/_console.py +0 -0
  14. {softmax_cli-0.26.16 → softmax_cli-0.26.18}/src/softmax/cogames.py +0 -0
  15. {softmax_cli-0.26.16 → softmax_cli-0.26.18}/src/softmax_cli.egg-info/SOURCES.txt +0 -0
  16. {softmax_cli-0.26.16 → softmax_cli-0.26.18}/src/softmax_cli.egg-info/dependency_links.txt +0 -0
  17. {softmax_cli-0.26.16 → softmax_cli-0.26.18}/src/softmax_cli.egg-info/entry_points.txt +0 -0
  18. {softmax_cli-0.26.16 → softmax_cli-0.26.18}/src/softmax_cli.egg-info/requires.txt +0 -0
  19. {softmax_cli-0.26.16 → softmax_cli-0.26.18}/src/softmax_cli.egg-info/top_level.txt +0 -0
  20. {softmax_cli-0.26.16 → softmax_cli-0.26.18}/tests/BUILD.bazel +0 -0
  21. {softmax_cli-0.26.16 → softmax_cli-0.26.18}/tests/test_auth_login.py +0 -0
  22. {softmax_cli-0.26.16 → softmax_cli-0.26.18}/tests/test_python_api.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: softmax-cli
3
- Version: 0.26.16
3
+ Version: 0.26.18
4
4
  Summary: Softmax CLI — authentication and account tools
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: Programming Language :: Python :: 3.11
@@ -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(f"Error: {e}")
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
- token = exchange_auth_code(api_server=api_server, code=code)
240
- save_user_token(server=api_server, token=token)
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 build_browser_login_url, exchange_auth_code, save_user_token
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
- try:
261
- token = exchange_auth_code(api_server=api_server, code=code)
262
- except Exception as exc:
263
- _finish_authentication(session, error=f"Code exchange failed: {exc}")
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
- try:
302
- token = exchange_auth_code(api_server=api_server, code=code)
303
- except Exception as exc:
304
- console.print(f"Code exchange failed: {exc}", style="red")
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()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: softmax-cli
3
- Version: 0.26.16
3
+ Version: 0.26.18
4
4
  Summary: Softmax CLI — authentication and account tools
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: Programming Language :: Python :: 3.11
File without changes
File without changes
File without changes
File without changes
File without changes