deepctl-core 0.3.0__tar.gz → 0.3.1__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 (34) hide show
  1. {deepctl_core-0.3.0 → deepctl_core-0.3.1}/PKG-INFO +1 -1
  2. {deepctl_core-0.3.0 → deepctl_core-0.3.1}/pyproject.toml +1 -1
  3. {deepctl_core-0.3.0 → deepctl_core-0.3.1}/src/deepctl_core/__init__.py +6 -0
  4. deepctl_core-0.3.1/src/deepctl_core/_launcher.py +67 -0
  5. deepctl_core-0.3.1/src/deepctl_core/_plugin_trust.py +118 -0
  6. {deepctl_core-0.3.0 → deepctl_core-0.3.1}/src/deepctl_core/auth.py +123 -25
  7. {deepctl_core-0.3.0 → deepctl_core-0.3.1}/src/deepctl_core/base_command.py +70 -55
  8. {deepctl_core-0.3.0 → deepctl_core-0.3.1}/src/deepctl_core/client.py +111 -44
  9. deepctl_core-0.3.1/src/deepctl_core/config.py +2316 -0
  10. deepctl_core-0.3.1/src/deepctl_core/hosts.py +410 -0
  11. deepctl_core-0.3.1/src/deepctl_core/messages.py +344 -0
  12. {deepctl_core-0.3.0 → deepctl_core-0.3.1}/src/deepctl_core/models.py +12 -0
  13. {deepctl_core-0.3.0 → deepctl_core-0.3.1}/src/deepctl_core/output.py +96 -6
  14. deepctl_core-0.3.1/src/deepctl_core/plugin_env.py +341 -0
  15. deepctl_core-0.3.1/src/deepctl_core/plugin_manager.py +1215 -0
  16. deepctl_core-0.3.1/src/deepctl_core/safe_text.py +27 -0
  17. {deepctl_core-0.3.0 → deepctl_core-0.3.1}/src/deepctl_core/skill_bundle.py +3 -3
  18. deepctl_core-0.3.1/src/deepctl_core/telemetry_context.py +72 -0
  19. deepctl_core-0.3.1/src/deepctl_core/tools.py +48 -0
  20. deepctl_core-0.3.1/src/deepctl_core/websocket.py +192 -0
  21. {deepctl_core-0.3.0 → deepctl_core-0.3.1}/src/deepctl_core.egg-info/PKG-INFO +1 -1
  22. {deepctl_core-0.3.0 → deepctl_core-0.3.1}/src/deepctl_core.egg-info/SOURCES.txt +8 -0
  23. deepctl_core-0.3.0/src/deepctl_core/config.py +0 -320
  24. deepctl_core-0.3.0/src/deepctl_core/plugin_env.py +0 -185
  25. deepctl_core-0.3.0/src/deepctl_core/plugin_manager.py +0 -678
  26. {deepctl_core-0.3.0 → deepctl_core-0.3.1}/README.md +0 -0
  27. {deepctl_core-0.3.0 → deepctl_core-0.3.1}/setup.cfg +0 -0
  28. {deepctl_core-0.3.0 → deepctl_core-0.3.1}/src/deepctl_core/base_group_command.py +0 -0
  29. {deepctl_core-0.3.0 → deepctl_core-0.3.1}/src/deepctl_core/py.typed +0 -0
  30. {deepctl_core-0.3.0 → deepctl_core-0.3.1}/src/deepctl_core/skill_generator.py +0 -0
  31. {deepctl_core-0.3.0 → deepctl_core-0.3.1}/src/deepctl_core/timing.py +0 -0
  32. {deepctl_core-0.3.0 → deepctl_core-0.3.1}/src/deepctl_core.egg-info/dependency_links.txt +0 -0
  33. {deepctl_core-0.3.0 → deepctl_core-0.3.1}/src/deepctl_core.egg-info/requires.txt +0 -0
  34. {deepctl_core-0.3.0 → deepctl_core-0.3.1}/src/deepctl_core.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepctl-core
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Core components for deepctl
5
5
  Author-email: Deepgram <devrel@deepgram.com>
6
6
  Maintainer-email: Deepgram <devrel@deepgram.com>
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "deepctl-core"
7
- version = "0.3.0" # x-release-please-version
7
+ version = "0.3.1" # x-release-please-version
8
8
  description = "Core components for deepctl"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -5,8 +5,10 @@ from .base_command import BaseCommand
5
5
  from .base_group_command import BaseGroupCommand
6
6
  from .client import DeepgramClient
7
7
  from .config import Config
8
+ from .hosts import CredentialHostError
8
9
  from .models import (
9
10
  BaseResult,
11
+ CodedErrorResult,
10
12
  ErrorResult,
11
13
  PluginInfo,
12
14
  ProfileInfo,
@@ -18,6 +20,7 @@ from .output import (
18
20
  get_output_format,
19
21
  get_status_console,
20
22
  is_agentic,
23
+ is_ai_agent,
21
24
  print_error,
22
25
  print_info,
23
26
  print_output,
@@ -41,7 +44,9 @@ __all__ = [
41
44
  "BaseCommand",
42
45
  "BaseGroupCommand",
43
46
  "BaseResult",
47
+ "CodedErrorResult",
44
48
  "Config",
49
+ "CredentialHostError",
45
50
  "DeepgramClient",
46
51
  "ErrorResult",
47
52
  "OutputFormatter",
@@ -56,6 +61,7 @@ __all__ = [
56
61
  "get_status_console",
57
62
  "get_timing_summary",
58
63
  "is_agentic",
64
+ "is_ai_agent",
59
65
  "is_timing_enabled",
60
66
  "print_error",
61
67
  "print_info",
@@ -0,0 +1,67 @@
1
+ """Which wrapper, if any, started deepctl (internal; not a public API).
2
+
3
+ The ``@deepgram/cli`` npm package runs deepctl from a private venv and says
4
+ so in ``DEEPCTL_LAUNCHER``: ``npm`` for an installed package (global, or a
5
+ project dependency: the launcher can't tell them apart), ``npx`` for
6
+ ``npx``/``npm exec``. Nothing else sets it.
7
+
8
+ The variable is read once and removed from ``os.environ``, so it describes
9
+ this process only. Anything deepctl spawns -- a starter app, a shell, another
10
+ ``dg`` -- must not inherit a claim about how *it* was installed.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import sys
17
+
18
+ LAUNCHER_ENV_VAR = "DEEPCTL_LAUNCHER"
19
+ NPM_LAUNCHERS = frozenset({"npm", "npx"})
20
+
21
+ _captured = False
22
+ _launcher: str | None = None
23
+
24
+
25
+ def capture_launcher() -> str | None:
26
+ """The launcher that started deepctl, captured on the first call.
27
+
28
+ Reads and removes ``DEEPCTL_LAUNCHER``, unless ``deepctl.main`` already
29
+ did and says so with ``LAUNCHER_CAPTURED``: then its ``LAUNCHER`` is the
30
+ value. From 0.5.0 (#165), ``deepctl.main`` is stdlib-only and removes the
31
+ variable before deepctl_core is imported, so it is gone from
32
+ ``os.environ`` by the time this runs. A ``deepctl.main`` that calls this
33
+ function itself sets no flag, and the check falls through.
34
+ """
35
+ global _captured, _launcher
36
+ if not _captured:
37
+ entry = sys.modules.get("deepctl.main")
38
+ if entry is not None and getattr(entry, "LAUNCHER_CAPTURED", False):
39
+ _launcher = entry.LAUNCHER
40
+ else:
41
+ _launcher = os.environ.pop(LAUNCHER_ENV_VAR, None) or None
42
+ _captured = True
43
+ return _launcher
44
+
45
+
46
+ def get_launcher() -> str | None:
47
+ """Return the launcher that started deepctl, or ``None``."""
48
+ return capture_launcher()
49
+
50
+
51
+ def launched_via_npm() -> bool:
52
+ """Return True if the npm package (global install or npx) started deepctl."""
53
+ return get_launcher() in NPM_LAUNCHERS
54
+
55
+
56
+ def npm_upgrade_command() -> str:
57
+ """Return the command that upgrades an npm-launched deepctl."""
58
+ if get_launcher() == "npx":
59
+ return "npx @deepgram/cli@latest"
60
+ return "npm install -g @deepgram/cli@latest"
61
+
62
+
63
+ def reset_launcher_for_tests() -> None:
64
+ """Forget the captured value so the next call re-reads the environment."""
65
+ global _captured, _launcher
66
+ _captured = False
67
+ _launcher = None
@@ -0,0 +1,118 @@
1
+ """Trusted locations for plugin settings and the plugin venv.
2
+
3
+ Built on the config module's real-home trust helpers: the account's home
4
+ comes from the OS (``HOME`` aside, the known-folder API on Windows), and in
5
+ an AI agent session the user config is read only through the checked open
6
+ of the trust root (no symlinked component, owned by this user, one link, no
7
+ group or other write).
8
+ """
9
+
10
+ import os
11
+ from pathlib import Path
12
+
13
+ import yaml
14
+
15
+ from . import config as _config
16
+ from .output import is_ai_agent
17
+
18
+ __all__ = [
19
+ "agent_plugin_venv",
20
+ "allow_override_names",
21
+ "contained_in",
22
+ "is_ai_agent",
23
+ "real_home_plugin_venv",
24
+ "trusted_user_config_file",
25
+ ]
26
+
27
+
28
+ def _agent_trust_root() -> Path | None:
29
+ """The trust root's config path, unless it lies inside the working
30
+ directory (and that directory isn't the home itself)."""
31
+ location = _config._trust_root_location()
32
+ home = _config._real_home()
33
+ if location is None or home is None:
34
+ return None
35
+ real = location[1]
36
+ try:
37
+ resolved = os.path.realpath(real)
38
+ cwd = os.path.realpath(os.getcwd())
39
+ at_home = cwd == os.path.realpath(home)
40
+ except (OSError, ValueError):
41
+ return None
42
+ if not at_home and _under(resolved, cwd):
43
+ return None
44
+ return real
45
+
46
+
47
+ def trusted_user_config_file() -> Path | None:
48
+ """The user config file at its default location. Under an AI-agent
49
+ signal, only the one derived from the real home directory."""
50
+ if is_ai_agent():
51
+ return _agent_trust_root()
52
+ return _config._default_config_file()
53
+
54
+
55
+ def allow_override_names() -> frozenset[str]:
56
+ """Command paths listed in ``plugins.allow_override`` of the trusted file.
57
+
58
+ Never read from a ``--config`` file, ``deepgram.yaml`` or the environment.
59
+ Under an AI-agent signal the file must also pass the trust-root checks.
60
+ Any read or parse problem yields an empty set.
61
+ """
62
+ try:
63
+ if is_ai_agent():
64
+ if _agent_trust_root() is None:
65
+ return frozenset()
66
+ root = _config._open_trust_root()
67
+ data = root[1] if root is not None else None
68
+ else:
69
+ _, data = _config._read_yaml_fd(_config._default_config_file())
70
+ except (OSError, ValueError, yaml.YAMLError):
71
+ return frozenset()
72
+ plugins = data.get("plugins") if isinstance(data, dict) else None
73
+ names = plugins.get("allow_override") if isinstance(plugins, dict) else None
74
+ if not isinstance(names, list):
75
+ return frozenset()
76
+ return frozenset(" ".join(n.split()) for n in names if isinstance(n, str))
77
+
78
+
79
+ def _under(path: str, directory: str) -> bool:
80
+ return path == directory or path.startswith(directory.rstrip(os.sep) + os.sep)
81
+
82
+
83
+ def real_home_plugin_venv() -> Path | None:
84
+ """``.deepctl/plugins/venv`` under the real home directory, unchecked."""
85
+ home = _config._real_home()
86
+ return None if home is None else Path(home, ".deepctl", "plugins", "venv")
87
+
88
+
89
+ def agent_plugin_venv() -> Path | None:
90
+ """The plugin venv to load under an AI-agent signal, or ``None``.
91
+
92
+ It is ``.deepctl/plugins/venv`` under the account's real home directory
93
+ (``HOME`` aside). ``None`` when the home can't be read, when a directory
94
+ below the home on the way to the venv is a symlink, or when the venv lies
95
+ inside the working directory and that directory isn't the home itself.
96
+ """
97
+ home = _config._real_home()
98
+ if home is None:
99
+ return None
100
+ try:
101
+ real_home = os.path.realpath(home)
102
+ venv = os.path.join(real_home, ".deepctl", "plugins", "venv")
103
+ if os.path.realpath(venv) != venv:
104
+ return None
105
+ cwd = os.path.realpath(os.getcwd())
106
+ except (OSError, ValueError):
107
+ return None
108
+ if cwd != real_home and _under(venv, cwd):
109
+ return None
110
+ return Path(venv)
111
+
112
+
113
+ def contained_in(path: Path, directory: Path) -> bool:
114
+ """True when ``path`` resolves inside ``directory`` (resolved)."""
115
+ try:
116
+ return _under(os.path.realpath(path), os.path.realpath(directory))
117
+ except (OSError, ValueError):
118
+ return False
@@ -10,10 +10,14 @@ import httpx
10
10
  import keyring
11
11
  from keyring.errors import PasswordDeleteError
12
12
  from pydantic import BaseModel
13
+ from rich.markup import escape
13
14
  from rich.progress import Progress, SpinnerColumn, TextColumn
14
15
 
16
+ from . import hosts, output
17
+ from . import messages as msg
15
18
  from .client import _split_base_url
16
19
  from .config import Config
20
+ from .hosts import check_credential_host, check_identity_host
17
21
  from .models import ProfileInfo, ProfilesResult
18
22
  from .output import stderr_console
19
23
 
@@ -22,10 +26,8 @@ from .output import stderr_console
22
26
  # command writes to stdout. See get_status_console() in output.py.
23
27
  console = stderr_console
24
28
 
25
- # Auth provider base URL (dx-id OIDC provider)
26
- AUTH_BASE_URL = os.getenv("DEEPGRAM_CLI_BASE_URL", "https://id.dx.deepgram.com")
27
- DEVICE_CODE_URL = f"{AUTH_BASE_URL}/device/code"
28
- TOKEN_POLL_URL = f"{AUTH_BASE_URL}/device/token"
29
+ # Auth provider (dx-id OIDC) base URL: hosts.DEFAULT_IDENTITY_URL, or
30
+ # DEEPGRAM_CLI_BASE_URL, resolved per login through check_identity_host().
29
31
 
30
32
  # Registered OIDC client ID for the CLI
31
33
  CLIENT_ID = "deepgram-cli"
@@ -54,6 +56,54 @@ class TokenResponse(BaseModel):
54
56
  dg_expires_in: int | None = None
55
57
 
56
58
 
59
+ def _insecure_allowed() -> bool:
60
+ """Whether DEEPGRAM_CLI_INSECURE may turn off TLS verification.
61
+
62
+ Only ``1`` or ``true`` count (``0``, ``false`` or anything else keeps
63
+ verification on), and never in an AI agent session. Even then it only
64
+ applies to localhost, 127.0.0.1 and [::1]: see ``_insecure_mounts``.
65
+ """
66
+ value = os.getenv("DEEPGRAM_CLI_INSECURE", "").strip().lower()
67
+ if value not in ("1", "true"):
68
+ return False
69
+ if output.is_ai_agent():
70
+ if hosts._first_time(("W-INSECURE-AGENT", "")):
71
+ output.print_security_warning(msg.W_INSECURE_AGENT)
72
+ return False
73
+ return True
74
+
75
+
76
+ #: Hosts DEEPGRAM_CLI_INSECURE applies to: local dev servers only. Other
77
+ #: 127.0.0.0/8 addresses keep verification (mounts match exact hosts).
78
+ _INSECURE_HOSTS = ("localhost", "127.0.0.1", "::1")
79
+
80
+
81
+ def _insecure_mounts() -> dict[str, httpx.BaseTransport]:
82
+ """Unverified transports for the loopback hosts only.
83
+
84
+ Host-specific mounts take precedence over the scheme-wide proxy mounts
85
+ httpx builds from HTTPS_PROXY/ALL_PROXY/NO_PROXY, so every other host
86
+ keeps both certificate verification and the proxy settings.
87
+ """
88
+ unverified = httpx.HTTPTransport(verify=False)
89
+ return {
90
+ f"all://{'[::1]' if host == '::1' else host}": unverified
91
+ for host in _INSECURE_HOSTS
92
+ }
93
+
94
+
95
+ def _warn_if_verified(request: httpx.Request) -> None:
96
+ """Say once per host that DEEPGRAM_CLI_INSECURE did not apply to it."""
97
+ host = request.url.host
98
+ if host in _INSECURE_HOSTS:
99
+ return
100
+ if hosts._first_time(("W-INSECURE-REMOTE", host)):
101
+ output.print_security_warning(
102
+ escape(msg.W_INSECURE_REMOTE.format(host=msg.display(host)))
103
+ )
104
+ output.print_hint(escape(msg.W_INSECURE_REMOTE_HINT))
105
+
106
+
57
107
  class AuthenticationError(Exception):
58
108
  """Authentication related errors."""
59
109
 
@@ -79,9 +129,27 @@ class AuthManager:
79
129
  self.config = config
80
130
  self.explicit_api_key = explicit_api_key
81
131
  self.explicit_project_id = explicit_project_id
82
- # Disable SSL verification for local dev (DEEPGRAM_CLI_INSECURE=1)
83
- verify = not bool(os.getenv("DEEPGRAM_CLI_INSECURE"))
84
- self.client = httpx.Client(timeout=30.0, verify=verify)
132
+ # DEEPGRAM_CLI_INSECURE=1 skips TLS verification for localhost,
133
+ # 127.0.0.1 and [::1] (local dev), never for any other host and never
134
+ # in an AI agent session: the agent may have set it. Proxy settings
135
+ # from the environment still apply to every other host.
136
+ if _insecure_allowed():
137
+ self.client = httpx.Client(
138
+ timeout=30.0,
139
+ verify=True,
140
+ mounts=_insecure_mounts(),
141
+ event_hooks={"request": [_warn_if_verified]},
142
+ )
143
+ else:
144
+ self.client = httpx.Client(timeout=30.0, verify=True)
145
+
146
+ def check_base_url(self) -> str:
147
+ """Return the base URL credentials may be sent to.
148
+
149
+ Raises ``hosts.CredentialHostError`` (already reported on stderr)
150
+ when the effective host isn't trusted; nothing has been sent.
151
+ """
152
+ return check_credential_host(self.config)
85
153
 
86
154
  def has_env_credentials(self) -> tuple[bool, bool]:
87
155
  """Check if environment variables are set.
@@ -294,17 +362,19 @@ class AuthManager:
294
362
  if not api_key:
295
363
  return False, "No API key provided or stored", "auth"
296
364
 
365
+ # Verify against the configured base URL (not hardcoded prod), so a
366
+ # custom base (e.g. staging) validates its own keys correctly -- but
367
+ # only once that host is trusted with the key.
368
+ base = self.check_base_url()
369
+
297
370
  try:
298
371
  headers = {
299
372
  "Authorization": f"Token {api_key}",
300
373
  "Content-Type": "application/json",
301
374
  }
302
375
 
303
- # Verify against the configured base URL (not hardcoded prod), so a
304
- # custom base (e.g. staging) validates its own keys correctly.
305
376
  # Preserve the input scheme so a plaintext endpoint isn't forced
306
377
  # onto TLS.
307
- base = self.config.get_profile().base_url or "https://api.deepgram.com"
308
378
  rest_scheme, _, host = _split_base_url(base)
309
379
  response = self.client.get(
310
380
  f"{rest_scheme}://{host}/v1/projects",
@@ -362,6 +432,8 @@ class AuthManager:
362
432
  if not api_key:
363
433
  return False, "No API key provided or stored", "auth"
364
434
 
435
+ base = self.check_base_url()
436
+
365
437
  # If no project_id, just verify the API key alone
366
438
  if not project_id:
367
439
  return self.verify_api_key(api_key)
@@ -373,9 +445,10 @@ class AuthManager:
373
445
  "Content-Type": "application/json",
374
446
  }
375
447
 
376
- # Make request to get project details
448
+ # Project details from the same (checked) base URL as the key
449
+ rest_scheme, _, host = _split_base_url(base)
377
450
  response = self.client.get(
378
- f"https://api.deepgram.com/v1/projects/{project_id}",
451
+ f"{rest_scheme}://{host}/v1/projects/{project_id}",
379
452
  headers=headers,
380
453
  )
381
454
 
@@ -412,9 +485,23 @@ class AuthManager:
412
485
 
413
486
  Only validates that the API key works. Project ID validation
414
487
  is handled separately by requires_project on BaseCommand.
488
+ Refuses first, before reading or sending any key, when the base URL
489
+ isn't trusted with credentials.
415
490
  """
491
+ self.check_base_url()
416
492
  api_key = self.get_api_key()
417
493
 
494
+ if not api_key and getattr(self.config, "api_key_untrusted", False) is True:
495
+ # The file's key was left unused (W-PERM-AGENT / W-CFG-UNTRUSTED):
496
+ # say so, and send the agent to a person, not to dg login.
497
+ from rich.markup import escape
498
+
499
+ text = msg.E_KEY_UNTRUSTED.format(
500
+ user_config=msg.display_path(self.config.config_path)
501
+ )
502
+ output.print_error(escape(text))
503
+ output.print_hint(msg.W_CFG_UNTRUSTED_FILE_HINT)
504
+ raise AuthenticationError(text)
418
505
  if not api_key:
419
506
  console.print(
420
507
  "[red]Error:[/red] DEEPGRAM_API_KEY is not set in the "
@@ -488,6 +575,8 @@ class AuthManager:
488
575
  # Store API key in keyring for security
489
576
  profile_name = self.config.profile or "default"
490
577
  keyring_available = False
578
+ # Nothing goes to the keyring when the profile can't be saved.
579
+ self.config.check_saveable()
491
580
 
492
581
  try:
493
582
  keyring.set_password(KEYRING_SERVICE, f"api-key.{profile_name}", api_key)
@@ -504,7 +593,6 @@ class AuthManager:
504
593
  profile_name,
505
594
  api_key=api_key if not keyring_available else None,
506
595
  project_id=project_id, # Always store project ID in config
507
- base_url=self.config.get_profile(profile_name).base_url,
508
596
  )
509
597
 
510
598
  console.print("[green]✓[/green] Successfully logged in with API key")
@@ -517,12 +605,15 @@ class AuthManager:
517
605
  """Login using device flow (interactive method)."""
518
606
  console.print("[blue]Starting device flow authentication...[/blue]")
519
607
 
608
+ # The token exchange mints the API key: check the identity host first.
609
+ identity_url = check_identity_host(self.config)
610
+
520
611
  try:
521
612
  # Get hostname for device identification
522
613
  hostname = os.uname().nodename if hasattr(os, "uname") else "unknown"
523
614
 
524
615
  # Request device code from dx-id auth provider
525
- device_response = self._request_device_code()
616
+ device_response = self._request_device_code(identity_url)
526
617
 
527
618
  verification_uri = (
528
619
  device_response.verification_uri_complete
@@ -549,7 +640,9 @@ class AuthManager:
549
640
  )
550
641
 
551
642
  # Poll for token (starts immediately — user may have clicked the link)
552
- token_response = self._poll_for_token(device_response, hostname)
643
+ token_response = self._poll_for_token(
644
+ device_response, hostname, identity_url
645
+ )
553
646
 
554
647
  # Store token and get user info
555
648
  self._store_token(token_response)
@@ -561,7 +654,9 @@ class AuthManager:
561
654
  console.print(f"[red]Error during device flow:[/red] {e}")
562
655
  raise AuthenticationError(f"Device flow failed: {e}")
563
656
 
564
- def _request_device_code(self) -> DeviceCodeResponse:
657
+ def _request_device_code(
658
+ self, identity_url: str | None = None
659
+ ) -> DeviceCodeResponse:
565
660
  """Request device code from auth provider.
566
661
 
567
662
  Returns:
@@ -576,8 +671,9 @@ class AuthManager:
576
671
  }
577
672
 
578
673
  try:
674
+ base = identity_url or check_identity_host(self.config)
579
675
  response = self.client.post(
580
- DEVICE_CODE_URL,
676
+ f"{base}/device/code",
581
677
  json=payload,
582
678
  headers={"Content-Type": "application/json"},
583
679
  )
@@ -596,6 +692,7 @@ class AuthManager:
596
692
  self,
597
693
  device_response: DeviceCodeResponse,
598
694
  hostname: str,
695
+ identity_url: str | None = None,
599
696
  ) -> TokenResponse:
600
697
  """Poll for token using device code."""
601
698
  console.print("\n[blue]Waiting for authentication...[/blue]")
@@ -608,7 +705,8 @@ class AuthManager:
608
705
  "hostname": hostname,
609
706
  }
610
707
 
611
- poll_url = f"{TOKEN_POLL_URL}?{urlencode(query_params)}"
708
+ base = identity_url or check_identity_host(self.config)
709
+ poll_url = f"{base}/device/token?{urlencode(query_params)}"
612
710
 
613
711
  with Progress(
614
712
  SpinnerColumn(),
@@ -666,6 +764,8 @@ class AuthManager:
666
764
  ).isoformat()
667
765
 
668
766
  keyring_available = False
767
+ # Nothing goes to the keyring when the profile can't be saved.
768
+ self.config.check_saveable()
669
769
  try:
670
770
  keyring.set_password(
671
771
  KEYRING_SERVICE, f"api-key.{profile_name}", api_key
@@ -684,7 +784,6 @@ class AuthManager:
684
784
  profile_name,
685
785
  api_key=api_key if not keyring_available else None,
686
786
  project_id=token_response.project_id,
687
- base_url=self.config.get_profile(profile_name).base_url,
688
787
  )
689
788
  profile = self.config.get_profile(profile_name)
690
789
  profile.dg_token_expires_at = expires_at
@@ -711,6 +810,8 @@ class AuthManager:
711
810
  and only clear credentials. If False, remove profile completely.
712
811
  """
713
812
  profile_name = self.config.profile or "default"
813
+ # Nothing is cleared from the keyring when the profile can't be saved.
814
+ self.config.check_saveable()
714
815
 
715
816
  # Clear all credential types from keyring.
716
817
  for keyring_key in (
@@ -727,12 +828,9 @@ class AuthManager:
727
828
  if keep_config:
728
829
  # Clear sensitive data from config but keep profile
729
830
  if profile_name in self.config.list_profiles():
730
- profile = self.config.get_profile(profile_name)
731
- self.config.create_profile(
732
- profile_name,
733
- api_key=None, # Clear API key
734
- project_id=profile.project_id, # Keep project ID
735
- base_url=profile.base_url, # Keep base URL
831
+ # Only the credential changes; project ID and base URL stay.
832
+ self.config.update_profile(
833
+ profile_name, api_key=None, dg_token_expires_at=None
736
834
  )
737
835
  else:
738
836
  # Clear profile completely from config