ntermqt 0.1.0__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.
Files changed (52) hide show
  1. nterm/__init__.py +54 -0
  2. nterm/__main__.py +619 -0
  3. nterm/askpass/__init__.py +22 -0
  4. nterm/askpass/server.py +393 -0
  5. nterm/config.py +158 -0
  6. nterm/connection/__init__.py +17 -0
  7. nterm/connection/profile.py +296 -0
  8. nterm/manager/__init__.py +29 -0
  9. nterm/manager/connect_dialog.py +322 -0
  10. nterm/manager/editor.py +262 -0
  11. nterm/manager/io.py +678 -0
  12. nterm/manager/models.py +346 -0
  13. nterm/manager/settings.py +264 -0
  14. nterm/manager/tree.py +493 -0
  15. nterm/resources.py +48 -0
  16. nterm/session/__init__.py +60 -0
  17. nterm/session/askpass_ssh.py +399 -0
  18. nterm/session/base.py +110 -0
  19. nterm/session/interactive_ssh.py +522 -0
  20. nterm/session/pty_transport.py +571 -0
  21. nterm/session/ssh.py +610 -0
  22. nterm/terminal/__init__.py +11 -0
  23. nterm/terminal/bridge.py +83 -0
  24. nterm/terminal/resources/terminal.html +253 -0
  25. nterm/terminal/resources/terminal.js +414 -0
  26. nterm/terminal/resources/xterm-addon-fit.min.js +8 -0
  27. nterm/terminal/resources/xterm-addon-unicode11.min.js +8 -0
  28. nterm/terminal/resources/xterm-addon-web-links.min.js +8 -0
  29. nterm/terminal/resources/xterm.css +209 -0
  30. nterm/terminal/resources/xterm.min.js +8 -0
  31. nterm/terminal/widget.py +380 -0
  32. nterm/theme/__init__.py +10 -0
  33. nterm/theme/engine.py +456 -0
  34. nterm/theme/stylesheet.py +377 -0
  35. nterm/theme/themes/clean.yaml +0 -0
  36. nterm/theme/themes/default.yaml +36 -0
  37. nterm/theme/themes/dracula.yaml +36 -0
  38. nterm/theme/themes/gruvbox_dark.yaml +36 -0
  39. nterm/theme/themes/gruvbox_hybrid.yaml +38 -0
  40. nterm/theme/themes/gruvbox_light.yaml +36 -0
  41. nterm/vault/__init__.py +32 -0
  42. nterm/vault/credential_manager.py +163 -0
  43. nterm/vault/keychain.py +135 -0
  44. nterm/vault/manager_ui.py +962 -0
  45. nterm/vault/profile.py +219 -0
  46. nterm/vault/resolver.py +250 -0
  47. nterm/vault/store.py +642 -0
  48. ntermqt-0.1.0.dist-info/METADATA +327 -0
  49. ntermqt-0.1.0.dist-info/RECORD +52 -0
  50. ntermqt-0.1.0.dist-info/WHEEL +5 -0
  51. ntermqt-0.1.0.dist-info/entry_points.txt +5 -0
  52. ntermqt-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,296 @@
1
+ """
2
+ Connection profiles - everything needed to establish and re-establish a connection.
3
+ """
4
+
5
+ from __future__ import annotations
6
+ from dataclasses import dataclass, field
7
+ from enum import Enum
8
+ from typing import Optional
9
+ import json
10
+ import yaml
11
+
12
+
13
+ class AuthMethod(Enum):
14
+ """Supported authentication methods."""
15
+ PASSWORD = "password"
16
+ KEY_FILE = "key_file"
17
+ KEY_STORED = "key_stored" # Key material in vault
18
+ AGENT = "agent" # ssh-agent (YubiKey/FIDO2)
19
+ KEYBOARD_INTERACTIVE = "keyboard_interactive"
20
+ CERTIFICATE = "certificate"
21
+
22
+ def requires_interaction(self) -> bool:
23
+ """Does this method potentially need user interaction?"""
24
+ return self in (
25
+ AuthMethod.AGENT, # YubiKey touch
26
+ AuthMethod.KEYBOARD_INTERACTIVE,
27
+ )
28
+
29
+
30
+ @dataclass
31
+ class AuthConfig:
32
+ """Authentication configuration for a single method."""
33
+ method: AuthMethod
34
+ username: str
35
+
36
+ # Password auth
37
+ password: Optional[str] = None
38
+ credential_ref: Optional[str] = None # Vault reference instead of plaintext
39
+
40
+ # Key auth
41
+ key_path: Optional[str] = None # Path to key file
42
+ key_data: Optional[str] = None # Raw key (from vault)
43
+ key_passphrase: Optional[str] = None
44
+
45
+ # Certificate auth
46
+ cert_path: Optional[str] = None
47
+
48
+ # Agent settings
49
+ agent_socket: Optional[str] = None # Override SSH_AUTH_SOCK
50
+
51
+ # Behavior
52
+ allow_agent_fallback: bool = False
53
+
54
+ def to_dict(self) -> dict:
55
+ """Serialize, excluding secrets."""
56
+ d = {
57
+ 'method': self.method.value,
58
+ 'username': self.username,
59
+ 'key_path': self.key_path,
60
+ 'cert_path': self.cert_path,
61
+ 'credential_ref': self.credential_ref,
62
+ 'allow_agent_fallback': self.allow_agent_fallback,
63
+ }
64
+ # Never serialize: password, key_data, key_passphrase
65
+ return {k: v for k, v in d.items() if v is not None}
66
+
67
+ @classmethod
68
+ def from_dict(cls, data: dict) -> AuthConfig:
69
+ """Deserialize from dict."""
70
+ data = data.copy()
71
+ data['method'] = AuthMethod(data['method'])
72
+ return cls(**data)
73
+
74
+ @classmethod
75
+ def password_auth(
76
+ cls,
77
+ username: str,
78
+ password: str = None,
79
+ credential_ref: str = None
80
+ ) -> AuthConfig:
81
+ """Factory for password auth."""
82
+ return cls(
83
+ method=AuthMethod.PASSWORD,
84
+ username=username,
85
+ password=password,
86
+ credential_ref=credential_ref,
87
+ )
88
+
89
+ @classmethod
90
+ def agent_auth(cls, username: str, allow_fallback: bool = False) -> AuthConfig:
91
+ """Factory for ssh-agent auth (YubiKey, etc)."""
92
+ return cls(
93
+ method=AuthMethod.AGENT,
94
+ username=username,
95
+ allow_agent_fallback=allow_fallback,
96
+ )
97
+
98
+ @classmethod
99
+ def key_file_auth(
100
+ cls,
101
+ username: str,
102
+ key_path: str,
103
+ passphrase: str = None
104
+ ) -> AuthConfig:
105
+ """Factory for key file auth."""
106
+ return cls(
107
+ method=AuthMethod.KEY_FILE,
108
+ username=username,
109
+ key_path=key_path,
110
+ key_passphrase=passphrase,
111
+ )
112
+
113
+ @classmethod
114
+ def stored_key_auth(
115
+ cls,
116
+ username: str,
117
+ credential_ref: str,
118
+ allow_fallback: bool = False
119
+ ) -> AuthConfig:
120
+ """Factory for vault-stored key auth."""
121
+ return cls(
122
+ method=AuthMethod.KEY_STORED,
123
+ username=username,
124
+ credential_ref=credential_ref,
125
+ allow_agent_fallback=allow_fallback,
126
+ )
127
+
128
+
129
+ @dataclass
130
+ class JumpHostConfig:
131
+ """Jump host / bastion configuration."""
132
+ hostname: str
133
+ port: int = 22
134
+ auth: AuthConfig = None
135
+
136
+ # Interaction hints for UI
137
+ requires_touch: bool = False
138
+ touch_prompt: str = "Touch your security key..."
139
+ banner_timeout: float = 30.0
140
+
141
+ def to_dict(self) -> dict:
142
+ """Serialize to dict."""
143
+ return {
144
+ 'hostname': self.hostname,
145
+ 'port': self.port,
146
+ 'auth': self.auth.to_dict() if self.auth else None,
147
+ 'requires_touch': self.requires_touch,
148
+ 'touch_prompt': self.touch_prompt,
149
+ 'banner_timeout': self.banner_timeout,
150
+ }
151
+
152
+ @classmethod
153
+ def from_dict(cls, data: dict) -> JumpHostConfig:
154
+ """Deserialize from dict."""
155
+ data = data.copy()
156
+ if data.get('auth'):
157
+ data['auth'] = AuthConfig.from_dict(data['auth'])
158
+ return cls(**data)
159
+
160
+
161
+ @dataclass
162
+ class ConnectionProfile:
163
+ """
164
+ Complete connection specification.
165
+
166
+ This is the "recipe" for a connection - everything needed to
167
+ establish it, and re-establish it if dropped.
168
+ """
169
+ name: str
170
+ hostname: str
171
+ port: int = 22
172
+
173
+ # Auth methods - tried in order until one succeeds
174
+ auth_methods: list[AuthConfig] = field(default_factory=list)
175
+
176
+ # Jump host chain (in order)
177
+ jump_hosts: list[JumpHostConfig] = field(default_factory=list)
178
+
179
+ # Terminal settings
180
+ term_type: str = "xterm-256color"
181
+ term_cols: int = 120
182
+ term_rows: int = 40
183
+
184
+ # Connection behavior
185
+ keepalive_interval: int = 30
186
+ keepalive_count_max: int = 3
187
+ connect_timeout: float = 30.0
188
+
189
+ # Reconnection policy
190
+ auto_reconnect: bool = True
191
+ reconnect_delay: float = 2.0
192
+ reconnect_max_attempts: int = 5
193
+ reconnect_backoff: float = 1.5 # Exponential backoff multiplier
194
+
195
+ # Matching rules (for credential resolver)
196
+ match_patterns: list[str] = field(default_factory=list)
197
+ tags: list[str] = field(default_factory=list)
198
+
199
+ # Metadata
200
+ description: str = ""
201
+ group: str = "" # For UI grouping
202
+
203
+ def to_dict(self) -> dict:
204
+ """Serialize to dict (for saving)."""
205
+ return {
206
+ 'name': self.name,
207
+ 'hostname': self.hostname,
208
+ 'port': self.port,
209
+ 'auth_methods': [a.to_dict() for a in self.auth_methods],
210
+ 'jump_hosts': [j.to_dict() for j in self.jump_hosts],
211
+ 'term_type': self.term_type,
212
+ 'term_cols': self.term_cols,
213
+ 'term_rows': self.term_rows,
214
+ 'keepalive_interval': self.keepalive_interval,
215
+ 'keepalive_count_max': self.keepalive_count_max,
216
+ 'connect_timeout': self.connect_timeout,
217
+ 'auto_reconnect': self.auto_reconnect,
218
+ 'reconnect_delay': self.reconnect_delay,
219
+ 'reconnect_max_attempts': self.reconnect_max_attempts,
220
+ 'reconnect_backoff': self.reconnect_backoff,
221
+ 'match_patterns': self.match_patterns,
222
+ 'tags': self.tags,
223
+ 'description': self.description,
224
+ 'group': self.group,
225
+ }
226
+
227
+ @classmethod
228
+ def from_dict(cls, data: dict) -> ConnectionProfile:
229
+ """Deserialize from dict."""
230
+ data = data.copy()
231
+ data['auth_methods'] = [
232
+ AuthConfig.from_dict(a) for a in data.get('auth_methods', [])
233
+ ]
234
+ data['jump_hosts'] = [
235
+ JumpHostConfig.from_dict(j) for j in data.get('jump_hosts', [])
236
+ ]
237
+ return cls(**data)
238
+
239
+ def to_yaml(self) -> str:
240
+ """Serialize to YAML string."""
241
+ return yaml.dump(self.to_dict(), default_flow_style=False, sort_keys=False)
242
+
243
+ @classmethod
244
+ def from_yaml(cls, yaml_str: str) -> ConnectionProfile:
245
+ """Deserialize from YAML string."""
246
+ return cls.from_dict(yaml.safe_load(yaml_str))
247
+
248
+ def to_json(self, indent: int = 2) -> str:
249
+ """Serialize to JSON string."""
250
+ return json.dumps(self.to_dict(), indent=indent)
251
+
252
+ @classmethod
253
+ def from_json(cls, json_str: str) -> ConnectionProfile:
254
+ """Deserialize from JSON string."""
255
+ return cls.from_dict(json.loads(json_str))
256
+
257
+ def save(self, path: str) -> None:
258
+ """Save to file (YAML or JSON based on extension)."""
259
+ from pathlib import Path
260
+ p = Path(path)
261
+ content = self.to_yaml() if p.suffix in ('.yaml', '.yml') else self.to_json()
262
+ p.write_text(content)
263
+
264
+ @classmethod
265
+ def load(cls, path: str) -> ConnectionProfile:
266
+ """Load from file (YAML or JSON based on extension)."""
267
+ from pathlib import Path
268
+ p = Path(path)
269
+ content = p.read_text()
270
+ if p.suffix in ('.yaml', '.yml'):
271
+ return cls.from_yaml(content)
272
+ return cls.from_json(content)
273
+
274
+ @property
275
+ def requires_interaction(self) -> bool:
276
+ """Does this connection require user interaction to establish?"""
277
+ for jump in self.jump_hosts:
278
+ if jump.requires_touch:
279
+ return True
280
+ if jump.auth and jump.auth.method.requires_interaction():
281
+ return True
282
+ return any(a.method.requires_interaction() for a in self.auth_methods)
283
+
284
+ @property
285
+ def display_name(self) -> str:
286
+ """User-friendly display string."""
287
+ if self.jump_hosts:
288
+ chain = " → ".join(j.hostname for j in self.jump_hosts)
289
+ return f"{chain} → {self.hostname}"
290
+ return self.hostname
291
+
292
+ def clone(self, **overrides) -> ConnectionProfile:
293
+ """Create a copy with optional overrides."""
294
+ data = self.to_dict()
295
+ data.update(overrides)
296
+ return ConnectionProfile.from_dict(data)
@@ -0,0 +1,29 @@
1
+ """
2
+ Session manager - tree-based session browser and storage.
3
+ """
4
+
5
+ from .models import SavedSession, SessionFolder, SessionStore
6
+ from .tree import SessionTreeWidget
7
+ from .editor import SessionEditorDialog, QuickConnectDialog
8
+ from .settings import SettingsDialog, ThemePreview
9
+ from .io import (
10
+ export_sessions, import_sessions, import_terminal_telemetry,
11
+ ExportDialog, ImportDialog, ImportTerminalTelemetryDialog
12
+ )
13
+
14
+ __all__ = [
15
+ "SavedSession",
16
+ "SessionFolder",
17
+ "SessionStore",
18
+ "SessionTreeWidget",
19
+ "SessionEditorDialog",
20
+ "QuickConnectDialog",
21
+ "SettingsDialog",
22
+ "ThemePreview",
23
+ "export_sessions",
24
+ "import_sessions",
25
+ "import_terminal_telemetry",
26
+ "ExportDialog",
27
+ "ImportDialog",
28
+ "ImportTerminalTelemetryDialog",
29
+ ]
@@ -0,0 +1,322 @@
1
+ """
2
+ SSH Connection Dialog - Authentication configuration before connecting.
3
+ """
4
+
5
+ from pathlib import Path
6
+ from typing import Optional, List
7
+ from PyQt6.QtWidgets import (
8
+ QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, QGroupBox,
9
+ QLabel, QLineEdit, QPushButton, QComboBox, QCheckBox,
10
+ QFileDialog, QTabWidget, QWidget, QMessageBox
11
+ )
12
+ from PyQt6.QtCore import Qt
13
+
14
+ from nterm.connection.profile import ConnectionProfile, AuthConfig, AuthMethod
15
+ from nterm.vault.resolver import CredentialResolver
16
+ from nterm.manager import SavedSession
17
+
18
+
19
+ class ConnectDialog(QDialog):
20
+ """
21
+ Connection dialog for SSH authentication.
22
+
23
+ Shows target host info and allows selecting/configuring auth method.
24
+ Pre-fills from saved session credentials if available.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ session: SavedSession,
30
+ credential_resolver: CredentialResolver = None,
31
+ credential_names: List[str] = None,
32
+ parent=None
33
+ ):
34
+ super().__init__(parent)
35
+ self.session = session
36
+ self.credential_resolver = credential_resolver
37
+ self.credential_names = credential_names or []
38
+
39
+ self._profile: Optional[ConnectionProfile] = None
40
+
41
+ self.setWindowTitle(f"Connect to {session.name}")
42
+ self.setMinimumWidth(450)
43
+ self.setModal(True)
44
+
45
+ self._setup_ui()
46
+ self._load_defaults()
47
+
48
+ def _setup_ui(self):
49
+ """Build the dialog UI."""
50
+ layout = QVBoxLayout(self)
51
+
52
+ # Target info header
53
+ header = QGroupBox("Target")
54
+ header_layout = QFormLayout(header)
55
+
56
+ self._host_label = QLabel(f"{self.session.hostname}:{self.session.port}")
57
+ self._host_label.setStyleSheet("font-weight: bold;")
58
+ header_layout.addRow("Host:", self._host_label)
59
+
60
+ layout.addWidget(header)
61
+
62
+ # Auth method tabs
63
+ self._tabs = QTabWidget()
64
+
65
+ # Tab 1: Saved Credential
66
+ if self.credential_names:
67
+ cred_tab = QWidget()
68
+ cred_layout = QVBoxLayout(cred_tab)
69
+
70
+ form = QFormLayout()
71
+ self._cred_combo = QComboBox()
72
+ self._cred_combo.addItem("(none)", None)
73
+ for name in self.credential_names:
74
+ self._cred_combo.addItem(name, name)
75
+
76
+ # Pre-select if session has a credential
77
+ if self.session.credential_name:
78
+ idx = self._cred_combo.findData(self.session.credential_name)
79
+ if idx >= 0:
80
+ self._cred_combo.setCurrentIndex(idx)
81
+
82
+ form.addRow("Credential:", self._cred_combo)
83
+ cred_layout.addLayout(form)
84
+
85
+ # Show credential details
86
+ self._cred_info = QLabel()
87
+ self._cred_info.setWordWrap(True)
88
+ self._cred_info.setStyleSheet("color: #888; font-size: 11px;")
89
+ cred_layout.addWidget(self._cred_info)
90
+
91
+ self._cred_combo.currentIndexChanged.connect(self._on_credential_changed)
92
+ self._on_credential_changed() # Initial update
93
+
94
+ cred_layout.addStretch()
95
+ self._tabs.addTab(cred_tab, "Saved Credential")
96
+
97
+ # Tab 2: Password Auth
98
+ pw_tab = QWidget()
99
+ pw_layout = QFormLayout(pw_tab)
100
+
101
+ self._pw_username = QLineEdit()
102
+ self._pw_username.setPlaceholderText("e.g., admin")
103
+ pw_layout.addRow("Username:", self._pw_username)
104
+
105
+ self._pw_password = QLineEdit()
106
+ self._pw_password.setEchoMode(QLineEdit.EchoMode.Password)
107
+ self._pw_password.setPlaceholderText("Password")
108
+ pw_layout.addRow("Password:", self._pw_password)
109
+
110
+ self._tabs.addTab(pw_tab, "Password")
111
+
112
+ # Tab 3: Key File Auth
113
+ key_tab = QWidget()
114
+ key_layout = QFormLayout(key_tab)
115
+
116
+ self._key_username = QLineEdit()
117
+ self._key_username.setPlaceholderText("e.g., admin")
118
+ key_layout.addRow("Username:", self._key_username)
119
+
120
+ key_path_layout = QHBoxLayout()
121
+ self._key_path = QLineEdit()
122
+ self._key_path.setPlaceholderText("~/.ssh/id_rsa")
123
+ key_path_layout.addWidget(self._key_path)
124
+
125
+ browse_btn = QPushButton("Browse...")
126
+ browse_btn.clicked.connect(self._browse_key)
127
+ key_path_layout.addWidget(browse_btn)
128
+ key_layout.addRow("Key File:", key_path_layout)
129
+
130
+ self._key_passphrase = QLineEdit()
131
+ self._key_passphrase.setEchoMode(QLineEdit.EchoMode.Password)
132
+ self._key_passphrase.setPlaceholderText("(optional)")
133
+ key_layout.addRow("Passphrase:", self._key_passphrase)
134
+
135
+ self._tabs.addTab(key_tab, "Key File")
136
+
137
+ # Tab 4: SSH Agent
138
+ agent_tab = QWidget()
139
+ agent_layout = QVBoxLayout(agent_tab)
140
+
141
+ agent_form = QFormLayout()
142
+ self._agent_username = QLineEdit()
143
+ self._agent_username.setPlaceholderText("e.g., admin")
144
+ agent_form.addRow("Username:", self._agent_username)
145
+ agent_layout.addLayout(agent_form)
146
+
147
+ agent_info = QLabel(
148
+ "Uses keys loaded in your SSH agent (ssh-agent, gpg-agent, etc.).\n"
149
+ "Make sure your agent is running and has keys loaded."
150
+ )
151
+ agent_info.setStyleSheet("color: #888; font-size: 11px;")
152
+ agent_info.setWordWrap(True)
153
+ agent_layout.addWidget(agent_info)
154
+ agent_layout.addStretch()
155
+
156
+ self._tabs.addTab(agent_tab, "SSH Agent")
157
+
158
+ layout.addWidget(self._tabs)
159
+
160
+ # Buttons
161
+ btn_layout = QHBoxLayout()
162
+ btn_layout.addStretch()
163
+
164
+ cancel_btn = QPushButton("Cancel")
165
+ cancel_btn.clicked.connect(self.reject)
166
+ btn_layout.addWidget(cancel_btn)
167
+
168
+ self._connect_btn = QPushButton("Connect")
169
+ self._connect_btn.setDefault(True)
170
+ self._connect_btn.clicked.connect(self._on_connect)
171
+ btn_layout.addWidget(self._connect_btn)
172
+
173
+ layout.addLayout(btn_layout)
174
+
175
+ def _load_defaults(self):
176
+ """Load default values from session."""
177
+ import os
178
+ default_user = os.environ.get("USER", "admin")
179
+
180
+ # Set defaults for all username fields
181
+ self._pw_username.setText(default_user)
182
+ self._key_username.setText(default_user)
183
+ self._agent_username.setText(default_user)
184
+
185
+ # Default key path
186
+ default_key = Path.home() / ".ssh" / "id_rsa"
187
+ if default_key.exists():
188
+ self._key_path.setText(str(default_key))
189
+
190
+ # If session has a credential, select the credential tab
191
+ if self.session.credential_name and self.credential_names:
192
+ self._tabs.setCurrentIndex(0)
193
+ else:
194
+ # Default to SSH Agent tab if no saved credential
195
+ self._tabs.setCurrentIndex(self._tabs.count() - 1)
196
+
197
+ def _on_credential_changed(self):
198
+ """Update credential info display."""
199
+ if not hasattr(self, '_cred_combo'):
200
+ return
201
+
202
+ cred_name = self._cred_combo.currentData()
203
+ if cred_name and self.credential_resolver:
204
+ try:
205
+ # Try to get credential info
206
+ creds = self.credential_resolver.list_credentials()
207
+ for cred in creds:
208
+ if cred.name == cred_name:
209
+ info_parts = [f"Username: {cred.username}"]
210
+ if hasattr(cred, 'auth_type'):
211
+ info_parts.append(f"Type: {cred.auth_type}")
212
+ if hasattr(cred, 'has_key') and cred.has_key:
213
+ info_parts.append("Has SSH key")
214
+ self._cred_info.setText("\n".join(info_parts))
215
+ return
216
+ except Exception as e:
217
+ self._cred_info.setText(f"Error loading credential: {e}")
218
+ return
219
+
220
+ self._cred_info.setText("")
221
+
222
+ def _browse_key(self):
223
+ """Browse for SSH key file."""
224
+ ssh_dir = Path.home() / ".ssh"
225
+ path, _ = QFileDialog.getOpenFileName(
226
+ self,
227
+ "Select SSH Key",
228
+ str(ssh_dir) if ssh_dir.exists() else str(Path.home()),
229
+ "All Files (*)"
230
+ )
231
+ if path:
232
+ self._key_path.setText(path)
233
+
234
+ def _on_connect(self):
235
+ """Build profile and accept dialog."""
236
+ try:
237
+ self._profile = self._build_profile()
238
+ if self._profile:
239
+ self.accept()
240
+ except Exception as e:
241
+ QMessageBox.warning(self, "Error", f"Failed to build connection profile:\n{e}")
242
+
243
+ def _build_profile(self) -> Optional[ConnectionProfile]:
244
+ """Build ConnectionProfile from current settings."""
245
+ tab_index = self._tabs.currentIndex()
246
+ tab_text = self._tabs.tabText(tab_index)
247
+
248
+ auth_methods = []
249
+
250
+ if "Saved Credential" in tab_text:
251
+ cred_name = self._cred_combo.currentData()
252
+ if cred_name and self.credential_resolver:
253
+ try:
254
+ profile = self.credential_resolver.create_profile_for_credential(
255
+ cred_name,
256
+ self.session.hostname,
257
+ self.session.port
258
+ )
259
+ # DEBUG: Print what we got
260
+ print(f"DEBUG: Profile from vault for '{cred_name}':")
261
+ print(f" hostname: {profile.hostname}")
262
+ print(f" port: {profile.port}")
263
+ for i, auth in enumerate(profile.auth_methods):
264
+ print(f" auth[{i}]: method={auth.method}, user={auth.username}")
265
+ print(f" password set: {bool(auth.password)}")
266
+ print(f" credential_ref: {auth.credential_ref}")
267
+ print(f" key_path: {auth.key_path}")
268
+ print(f" key_data set: {bool(auth.key_data)}")
269
+ return profile
270
+ except Exception as e:
271
+ raise ValueError(f"Failed to load credential '{cred_name}': {e}")
272
+ else:
273
+ raise ValueError("No credential selected")
274
+
275
+ elif "Password" in tab_text:
276
+ username = self._pw_username.text().strip()
277
+ password = self._pw_password.text()
278
+
279
+ if not username:
280
+ raise ValueError("Username required")
281
+ if not password:
282
+ raise ValueError("Password required")
283
+
284
+ auth_methods.append(AuthConfig.password_auth(username, password))
285
+
286
+ elif "Key File" in tab_text:
287
+ username = self._key_username.text().strip()
288
+ key_path = self._key_path.text().strip()
289
+ passphrase = self._key_passphrase.text() or None
290
+
291
+ if not username:
292
+ raise ValueError("Username required")
293
+ if not key_path:
294
+ raise ValueError("Key file path required")
295
+
296
+ key_path_obj = Path(key_path).expanduser()
297
+ if not key_path_obj.exists():
298
+ raise ValueError(f"Key file not found: {key_path}")
299
+
300
+ auth_methods.append(AuthConfig.key_file_auth(
301
+ username=username,
302
+ key_path=str(key_path_obj),
303
+ passphrase=passphrase
304
+ ))
305
+
306
+ elif "Agent" in tab_text:
307
+ username = self._agent_username.text().strip()
308
+ if not username:
309
+ raise ValueError("Username required")
310
+
311
+ auth_methods.append(AuthConfig.agent_auth(username))
312
+
313
+ return ConnectionProfile(
314
+ name=self.session.name,
315
+ hostname=self.session.hostname,
316
+ port=self.session.port,
317
+ auth_methods=auth_methods,
318
+ )
319
+
320
+ def get_profile(self) -> Optional[ConnectionProfile]:
321
+ """Get the built connection profile (after accept)."""
322
+ return self._profile