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.
- {deepctl_core-0.3.0 → deepctl_core-0.3.1}/PKG-INFO +1 -1
- {deepctl_core-0.3.0 → deepctl_core-0.3.1}/pyproject.toml +1 -1
- {deepctl_core-0.3.0 → deepctl_core-0.3.1}/src/deepctl_core/__init__.py +6 -0
- deepctl_core-0.3.1/src/deepctl_core/_launcher.py +67 -0
- deepctl_core-0.3.1/src/deepctl_core/_plugin_trust.py +118 -0
- {deepctl_core-0.3.0 → deepctl_core-0.3.1}/src/deepctl_core/auth.py +123 -25
- {deepctl_core-0.3.0 → deepctl_core-0.3.1}/src/deepctl_core/base_command.py +70 -55
- {deepctl_core-0.3.0 → deepctl_core-0.3.1}/src/deepctl_core/client.py +111 -44
- deepctl_core-0.3.1/src/deepctl_core/config.py +2316 -0
- deepctl_core-0.3.1/src/deepctl_core/hosts.py +410 -0
- deepctl_core-0.3.1/src/deepctl_core/messages.py +344 -0
- {deepctl_core-0.3.0 → deepctl_core-0.3.1}/src/deepctl_core/models.py +12 -0
- {deepctl_core-0.3.0 → deepctl_core-0.3.1}/src/deepctl_core/output.py +96 -6
- deepctl_core-0.3.1/src/deepctl_core/plugin_env.py +341 -0
- deepctl_core-0.3.1/src/deepctl_core/plugin_manager.py +1215 -0
- deepctl_core-0.3.1/src/deepctl_core/safe_text.py +27 -0
- {deepctl_core-0.3.0 → deepctl_core-0.3.1}/src/deepctl_core/skill_bundle.py +3 -3
- deepctl_core-0.3.1/src/deepctl_core/telemetry_context.py +72 -0
- deepctl_core-0.3.1/src/deepctl_core/tools.py +48 -0
- deepctl_core-0.3.1/src/deepctl_core/websocket.py +192 -0
- {deepctl_core-0.3.0 → deepctl_core-0.3.1}/src/deepctl_core.egg-info/PKG-INFO +1 -1
- {deepctl_core-0.3.0 → deepctl_core-0.3.1}/src/deepctl_core.egg-info/SOURCES.txt +8 -0
- deepctl_core-0.3.0/src/deepctl_core/config.py +0 -320
- deepctl_core-0.3.0/src/deepctl_core/plugin_env.py +0 -185
- deepctl_core-0.3.0/src/deepctl_core/plugin_manager.py +0 -678
- {deepctl_core-0.3.0 → deepctl_core-0.3.1}/README.md +0 -0
- {deepctl_core-0.3.0 → deepctl_core-0.3.1}/setup.cfg +0 -0
- {deepctl_core-0.3.0 → deepctl_core-0.3.1}/src/deepctl_core/base_group_command.py +0 -0
- {deepctl_core-0.3.0 → deepctl_core-0.3.1}/src/deepctl_core/py.typed +0 -0
- {deepctl_core-0.3.0 → deepctl_core-0.3.1}/src/deepctl_core/skill_generator.py +0 -0
- {deepctl_core-0.3.0 → deepctl_core-0.3.1}/src/deepctl_core/timing.py +0 -0
- {deepctl_core-0.3.0 → deepctl_core-0.3.1}/src/deepctl_core.egg-info/dependency_links.txt +0 -0
- {deepctl_core-0.3.0 → deepctl_core-0.3.1}/src/deepctl_core.egg-info/requires.txt +0 -0
- {deepctl_core-0.3.0 → deepctl_core-0.3.1}/src/deepctl_core.egg-info/top_level.txt +0 -0
|
@@ -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
|
|
26
|
-
|
|
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
|
-
#
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
#
|
|
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"
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
731
|
-
self.config.
|
|
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
|