ptn 0.2.7__tar.gz → 0.4.2__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 (78) hide show
  1. {ptn-0.2.7 → ptn-0.4.2}/PKG-INFO +70 -19
  2. {ptn-0.2.7 → ptn-0.4.2}/README.md +68 -18
  3. {ptn-0.2.7 → ptn-0.4.2}/porterminal/__init__.py +37 -8
  4. {ptn-0.2.7 → ptn-0.4.2}/porterminal/_version.py +2 -2
  5. {ptn-0.2.7 → ptn-0.4.2}/porterminal/app.py +25 -1
  6. {ptn-0.2.7 → ptn-0.4.2}/porterminal/application/ports/__init__.py +2 -0
  7. ptn-0.4.2/porterminal/application/ports/connection_registry_port.py +46 -0
  8. {ptn-0.2.7 → ptn-0.4.2}/porterminal/application/services/management_service.py +30 -55
  9. {ptn-0.2.7 → ptn-0.4.2}/porterminal/application/services/session_service.py +3 -11
  10. {ptn-0.2.7 → ptn-0.4.2}/porterminal/application/services/terminal_service.py +97 -56
  11. {ptn-0.2.7 → ptn-0.4.2}/porterminal/cli/args.py +91 -30
  12. {ptn-0.2.7 → ptn-0.4.2}/porterminal/cli/display.py +18 -16
  13. ptn-0.4.2/porterminal/cli/script_discovery.py +112 -0
  14. {ptn-0.2.7 → ptn-0.4.2}/porterminal/composition.py +8 -7
  15. {ptn-0.2.7 → ptn-0.4.2}/porterminal/config.py +12 -2
  16. {ptn-0.2.7 → ptn-0.4.2}/porterminal/container.py +4 -0
  17. {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/__init__.py +0 -9
  18. ptn-0.4.2/porterminal/domain/entities/output_buffer.py +124 -0
  19. {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/entities/tab.py +11 -10
  20. {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/services/__init__.py +0 -2
  21. {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/values/__init__.py +0 -4
  22. {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/values/environment_rules.py +3 -0
  23. ptn-0.4.2/porterminal/infrastructure/auth.py +131 -0
  24. {ptn-0.2.7 → ptn-0.4.2}/porterminal/infrastructure/cloudflared.py +13 -11
  25. {ptn-0.2.7 → ptn-0.4.2}/porterminal/infrastructure/config/shell_detector.py +115 -16
  26. {ptn-0.2.7 → ptn-0.4.2}/porterminal/infrastructure/repositories/in_memory_session.py +1 -4
  27. {ptn-0.2.7 → ptn-0.4.2}/porterminal/infrastructure/repositories/in_memory_tab.py +2 -10
  28. {ptn-0.2.7 → ptn-0.4.2}/porterminal/infrastructure/server.py +14 -2
  29. ptn-0.4.2/porterminal/pty/env.py +35 -0
  30. {ptn-0.2.7 → ptn-0.4.2}/porterminal/pty/manager.py +6 -4
  31. ptn-0.4.2/porterminal/static/assets/app-DlWNJWFE.js +87 -0
  32. ptn-0.4.2/porterminal/static/assets/app-xPAM7YhQ.css +1 -0
  33. {ptn-0.2.7 → ptn-0.4.2}/porterminal/static/index.html +14 -2
  34. {ptn-0.2.7 → ptn-0.4.2}/porterminal/updater.py +13 -5
  35. {ptn-0.2.7 → ptn-0.4.2}/pyproject.toml +1 -0
  36. ptn-0.2.7/porterminal/domain/entities/output_buffer.py +0 -69
  37. ptn-0.2.7/porterminal/pty/env.py +0 -97
  38. ptn-0.2.7/porterminal/static/assets/app-DQePboVd.css +0 -32
  39. ptn-0.2.7/porterminal/static/assets/app-DoBiVkTD.js +0 -72
  40. {ptn-0.2.7 → ptn-0.4.2}/.gitignore +0 -0
  41. {ptn-0.2.7 → ptn-0.4.2}/LICENSE +0 -0
  42. {ptn-0.2.7 → ptn-0.4.2}/porterminal/__main__.py +0 -0
  43. {ptn-0.2.7 → ptn-0.4.2}/porterminal/application/__init__.py +0 -0
  44. {ptn-0.2.7 → ptn-0.4.2}/porterminal/application/ports/connection_port.py +0 -0
  45. {ptn-0.2.7 → ptn-0.4.2}/porterminal/application/services/__init__.py +0 -0
  46. {ptn-0.2.7 → ptn-0.4.2}/porterminal/application/services/tab_service.py +0 -0
  47. {ptn-0.2.7 → ptn-0.4.2}/porterminal/asgi.py +0 -0
  48. {ptn-0.2.7 → ptn-0.4.2}/porterminal/cli/__init__.py +0 -0
  49. {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/entities/__init__.py +0 -0
  50. {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/entities/session.py +0 -0
  51. {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/ports/__init__.py +0 -0
  52. {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/ports/pty_port.py +0 -0
  53. {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/ports/session_repository.py +0 -0
  54. {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/ports/tab_repository.py +0 -0
  55. {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/services/environment_sanitizer.py +0 -0
  56. {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/services/rate_limiter.py +0 -0
  57. {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/services/session_limits.py +0 -0
  58. {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/services/tab_limits.py +0 -0
  59. {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/values/rate_limit_config.py +0 -0
  60. {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/values/session_id.py +0 -0
  61. {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/values/shell_command.py +0 -0
  62. {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/values/tab_id.py +0 -0
  63. {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/values/terminal_dimensions.py +0 -0
  64. {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/values/user_id.py +0 -0
  65. {ptn-0.2.7 → ptn-0.4.2}/porterminal/infrastructure/__init__.py +0 -0
  66. {ptn-0.2.7 → ptn-0.4.2}/porterminal/infrastructure/config/__init__.py +0 -0
  67. {ptn-0.2.7 → ptn-0.4.2}/porterminal/infrastructure/network.py +0 -0
  68. {ptn-0.2.7 → ptn-0.4.2}/porterminal/infrastructure/registry/__init__.py +0 -0
  69. {ptn-0.2.7 → ptn-0.4.2}/porterminal/infrastructure/registry/user_connection_registry.py +0 -0
  70. {ptn-0.2.7 → ptn-0.4.2}/porterminal/infrastructure/repositories/__init__.py +0 -0
  71. {ptn-0.2.7 → ptn-0.4.2}/porterminal/infrastructure/web/__init__.py +0 -0
  72. {ptn-0.2.7 → ptn-0.4.2}/porterminal/infrastructure/web/websocket_adapter.py +0 -0
  73. {ptn-0.2.7 → ptn-0.4.2}/porterminal/logging_setup.py +0 -0
  74. {ptn-0.2.7 → ptn-0.4.2}/porterminal/pty/__init__.py +0 -0
  75. {ptn-0.2.7 → ptn-0.4.2}/porterminal/pty/protocol.py +0 -0
  76. {ptn-0.2.7 → ptn-0.4.2}/porterminal/pty/unix.py +0 -0
  77. {ptn-0.2.7 → ptn-0.4.2}/porterminal/pty/windows.py +0 -0
  78. {ptn-0.2.7 → ptn-0.4.2}/porterminal/static/icon.svg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ptn
3
- Version: 0.2.7
3
+ Version: 0.4.2
4
4
  Summary: Web-based terminal accessible from phone via Cloudflare Tunnel
5
5
  Project-URL: Homepage, https://github.com/lyehe/porterminal
6
6
  Project-URL: Repository, https://github.com/lyehe/porterminal
@@ -26,6 +26,7 @@ Classifier: Topic :: Internet :: WWW/HTTP
26
26
  Classifier: Topic :: System :: Shells
27
27
  Classifier: Topic :: Terminals :: Terminal Emulators/X Terminals
28
28
  Requires-Python: >=3.12
29
+ Requires-Dist: bcrypt>=4.0.0
29
30
  Requires-Dist: fastapi>=0.104.0
30
31
  Requires-Dist: pydantic>=2.0
31
32
  Requires-Dist: pywinpty>=2.0.0; sys_platform == 'win32'
@@ -74,9 +75,10 @@ So I built something simpler: **run a command, scan a QR, start typing.**
74
75
  ## Features
75
76
 
76
77
  - **One command, instant access** - No SSH, no port forwarding, no config files. Cloudflare tunnel + QR code.
77
- - **Actually usable on mobile** - Essential buttons and gestures for everyday terminal use.
78
- - **Multi-tab shared sessions** - Run builds in one tab, tail logs in another. Sessions and tabs persist across reconnects.
79
- - **Cross-platform** - Windows (PowerShell, CMD, WSL), Linux/macOS (Bash, Zsh, Fish). Auto-detects your shells.
78
+ - **Actually usable on mobile** - Touch-optimized with momentum scrolling, pinch-to-zoom, swipe gestures, and modifier keys (Ctrl, Alt).
79
+ - **Full terminal apps** - vim, htop, less, tmux all work correctly with proper alt-screen buffer handling.
80
+ - **Persistent multi-tab sessions** - Sessions survive disconnects. Close the browser, switch networks, reconnect from another device—your shell and running processes are still there. Multiple devices can view the same session simultaneously.
81
+ - **Cross-platform** - Windows (PowerShell, CMD, WSL), Linux/macOS (Bash, Zsh, Fish, Nushell, and any shell via `$SHELL`). Auto-detects your shells.
80
82
 
81
83
  ## Install
82
84
 
@@ -101,46 +103,95 @@ Requires Python 3.12+ and [cloudflared](https://developers.cloudflare.com/cloudf
101
103
  ```bash
102
104
  ptn # Start in current directory
103
105
  ptn ~/projects/myapp # Start in specific folder
104
- ptn --no-tunnel # Local network only
105
- ptn -b # Run in background
106
- ptn -v # Verbose startup logs
107
- ptn --init # Create .ptn/ptn.yaml config
108
- ptn -V # Show version
109
- ptn -U # Update to latest version
110
- ptn --check-update # Check if update available
111
106
  ```
112
107
 
108
+ | Flag | Description |
109
+ |------|-------------|
110
+ | `-n, --no-tunnel` | Local network only (no Cloudflare tunnel) |
111
+ | `-b, --background` | Run in background and return immediately |
112
+ | `-p, --password` | Prompt for password to protect this session |
113
+ | `-dp, --default-password` | Toggle password requirement in config (on/off) |
114
+ | `-v, --verbose` | Show detailed startup logs |
115
+ | `-i, --init` | Create `.ptn/ptn.yaml` config with auto-discovered project scripts |
116
+ | `-u, --update` | Update to the latest version |
117
+ | `-c, --check-update` | Check if a newer version is available |
118
+ | `-V, --version` | Show version |
119
+
120
+ ## Mobile Gestures
121
+
122
+ | Gesture | Action |
123
+ |---------|--------|
124
+ | **Tap** | Focus terminal, clear selection |
125
+ | **Long-press** | Start text selection |
126
+ | **Double-tap** | Select word |
127
+ | **Swipe left/right** | Arrow keys (← →) |
128
+ | **Scroll** | Momentum scrolling with physics |
129
+ | **Pinch** | Zoom text (10-24px) |
130
+
131
+ **Modifier keys** (Ctrl, Alt, Shift): Tap once for sticky (one keystroke), double-tap for lock.
132
+
113
133
  ## Configuration
114
134
 
115
- Run `ptn --init` to create a starter config, or create `ptn.yaml` manually:
135
+ Run `ptn --init` to create a starter config. It auto-discovers project scripts from `package.json`, `pyproject.toml`, or `Makefile` and adds them as buttons:
136
+
137
+ ```bash
138
+ ptn -i
139
+ # Created: .ptn/ptn.yaml
140
+ # Discovered 3 project script(s): build, dev, test
141
+ ```
142
+
143
+ Or create `ptn.yaml` manually:
116
144
 
117
145
  ```yaml
146
+ # Terminal settings
147
+ terminal:
148
+ default_shell: nu # Default shell ID
149
+ shells: # Custom shell definitions
150
+ - id: nu
151
+ name: Nushell
152
+ command: nu
153
+ args: []
154
+
118
155
  # Custom buttons (appear in toolbar)
156
+ # row: 1 = default row, 2+ = additional rows
119
157
  buttons:
120
158
  - label: "claude"
121
159
  send:
122
160
  - "claude"
123
161
  - 100 # delay in ms
124
162
  - "\r"
125
- - label: "tmux"
126
- send: "tmux\r"
163
+ - label: "build"
164
+ send: "npm run build\r"
165
+ row: 2 # second button row
127
166
 
128
167
  # Update checker settings
129
168
  update:
130
169
  notify_on_startup: true # Show update notification
131
170
  check_interval: 86400 # Seconds between checks (default: 24h)
171
+
172
+ # Security settings
173
+ security:
174
+ require_password: true # Always prompt for password at startup
175
+ max_auth_attempts: 5 # Max failed attempts before disconnect
132
176
  ```
133
177
 
134
178
  Config is searched in order: `$PORTERMINAL_CONFIG_PATH`, `./ptn.yaml`, `./.ptn/ptn.yaml`, `~/.ptn/ptn.yaml`.
135
179
 
136
180
  ## Security
137
181
 
138
- > **Warning:** The URL is the only authentication. Anyone with the link has full terminal access.
182
+ Use password if your screen can be exposed to others:
183
+ ```bash
184
+ ptn -p # Prompt for password this session
185
+ ptn -dp # Enable password by default (toggle)
186
+ ```
187
+
188
+ Password is per-session (never saved to disk). See [docs/security.md](docs/security.md) for details.
189
+
190
+ ## Troubleshooting
191
+
192
+ **Connection fails?** Cloudflare tunnel sometimes blocks connections. Restart the server (`Ctrl+C`, then `ptn`) to get a fresh tunnel URL.
139
193
 
140
- - Don't share the URL
141
- - Stop the server when not in use (`Ctrl+C`)
142
- - Use `--no-tunnel` for local network only
143
- - Environment variables are sanitized (API keys, tokens stripped)
194
+ **Shell not detected?** Set your `$SHELL` environment variable or configure shells in `ptn.yaml`.
144
195
 
145
196
  ## Contributing
146
197
 
@@ -35,9 +35,10 @@ So I built something simpler: **run a command, scan a QR, start typing.**
35
35
  ## Features
36
36
 
37
37
  - **One command, instant access** - No SSH, no port forwarding, no config files. Cloudflare tunnel + QR code.
38
- - **Actually usable on mobile** - Essential buttons and gestures for everyday terminal use.
39
- - **Multi-tab shared sessions** - Run builds in one tab, tail logs in another. Sessions and tabs persist across reconnects.
40
- - **Cross-platform** - Windows (PowerShell, CMD, WSL), Linux/macOS (Bash, Zsh, Fish). Auto-detects your shells.
38
+ - **Actually usable on mobile** - Touch-optimized with momentum scrolling, pinch-to-zoom, swipe gestures, and modifier keys (Ctrl, Alt).
39
+ - **Full terminal apps** - vim, htop, less, tmux all work correctly with proper alt-screen buffer handling.
40
+ - **Persistent multi-tab sessions** - Sessions survive disconnects. Close the browser, switch networks, reconnect from another device—your shell and running processes are still there. Multiple devices can view the same session simultaneously.
41
+ - **Cross-platform** - Windows (PowerShell, CMD, WSL), Linux/macOS (Bash, Zsh, Fish, Nushell, and any shell via `$SHELL`). Auto-detects your shells.
41
42
 
42
43
  ## Install
43
44
 
@@ -62,46 +63,95 @@ Requires Python 3.12+ and [cloudflared](https://developers.cloudflare.com/cloudf
62
63
  ```bash
63
64
  ptn # Start in current directory
64
65
  ptn ~/projects/myapp # Start in specific folder
65
- ptn --no-tunnel # Local network only
66
- ptn -b # Run in background
67
- ptn -v # Verbose startup logs
68
- ptn --init # Create .ptn/ptn.yaml config
69
- ptn -V # Show version
70
- ptn -U # Update to latest version
71
- ptn --check-update # Check if update available
72
66
  ```
73
67
 
68
+ | Flag | Description |
69
+ |------|-------------|
70
+ | `-n, --no-tunnel` | Local network only (no Cloudflare tunnel) |
71
+ | `-b, --background` | Run in background and return immediately |
72
+ | `-p, --password` | Prompt for password to protect this session |
73
+ | `-dp, --default-password` | Toggle password requirement in config (on/off) |
74
+ | `-v, --verbose` | Show detailed startup logs |
75
+ | `-i, --init` | Create `.ptn/ptn.yaml` config with auto-discovered project scripts |
76
+ | `-u, --update` | Update to the latest version |
77
+ | `-c, --check-update` | Check if a newer version is available |
78
+ | `-V, --version` | Show version |
79
+
80
+ ## Mobile Gestures
81
+
82
+ | Gesture | Action |
83
+ |---------|--------|
84
+ | **Tap** | Focus terminal, clear selection |
85
+ | **Long-press** | Start text selection |
86
+ | **Double-tap** | Select word |
87
+ | **Swipe left/right** | Arrow keys (← →) |
88
+ | **Scroll** | Momentum scrolling with physics |
89
+ | **Pinch** | Zoom text (10-24px) |
90
+
91
+ **Modifier keys** (Ctrl, Alt, Shift): Tap once for sticky (one keystroke), double-tap for lock.
92
+
74
93
  ## Configuration
75
94
 
76
- Run `ptn --init` to create a starter config, or create `ptn.yaml` manually:
95
+ Run `ptn --init` to create a starter config. It auto-discovers project scripts from `package.json`, `pyproject.toml`, or `Makefile` and adds them as buttons:
96
+
97
+ ```bash
98
+ ptn -i
99
+ # Created: .ptn/ptn.yaml
100
+ # Discovered 3 project script(s): build, dev, test
101
+ ```
102
+
103
+ Or create `ptn.yaml` manually:
77
104
 
78
105
  ```yaml
106
+ # Terminal settings
107
+ terminal:
108
+ default_shell: nu # Default shell ID
109
+ shells: # Custom shell definitions
110
+ - id: nu
111
+ name: Nushell
112
+ command: nu
113
+ args: []
114
+
79
115
  # Custom buttons (appear in toolbar)
116
+ # row: 1 = default row, 2+ = additional rows
80
117
  buttons:
81
118
  - label: "claude"
82
119
  send:
83
120
  - "claude"
84
121
  - 100 # delay in ms
85
122
  - "\r"
86
- - label: "tmux"
87
- send: "tmux\r"
123
+ - label: "build"
124
+ send: "npm run build\r"
125
+ row: 2 # second button row
88
126
 
89
127
  # Update checker settings
90
128
  update:
91
129
  notify_on_startup: true # Show update notification
92
130
  check_interval: 86400 # Seconds between checks (default: 24h)
131
+
132
+ # Security settings
133
+ security:
134
+ require_password: true # Always prompt for password at startup
135
+ max_auth_attempts: 5 # Max failed attempts before disconnect
93
136
  ```
94
137
 
95
138
  Config is searched in order: `$PORTERMINAL_CONFIG_PATH`, `./ptn.yaml`, `./.ptn/ptn.yaml`, `~/.ptn/ptn.yaml`.
96
139
 
97
140
  ## Security
98
141
 
99
- > **Warning:** The URL is the only authentication. Anyone with the link has full terminal access.
142
+ Use password if your screen can be exposed to others:
143
+ ```bash
144
+ ptn -p # Prompt for password this session
145
+ ptn -dp # Enable password by default (toggle)
146
+ ```
147
+
148
+ Password is per-session (never saved to disk). See [docs/security.md](docs/security.md) for details.
149
+
150
+ ## Troubleshooting
151
+
152
+ **Connection fails?** Cloudflare tunnel sometimes blocks connections. Restart the server (`Ctrl+C`, then `ptn`) to get a fresh tunnel URL.
100
153
 
101
- - Don't share the URL
102
- - Stop the server when not in use (`Ctrl+C`)
103
- - Use `--no-tunnel` for local network only
104
- - Environment variables are sanitized (API keys, tokens stripped)
154
+ **Shell not detected?** Set your `$SHELL` environment variable or configure shells in `ptn.yaml`.
105
155
 
106
156
  ## Contributing
107
157
 
@@ -14,6 +14,7 @@ except ImportError:
14
14
  __version__ = "0.0.0-dev" # Fallback before first build
15
15
 
16
16
  import os
17
+ import signal
17
18
  import subprocess
18
19
  import sys
19
20
  import time
@@ -149,6 +150,30 @@ def main() -> int:
149
150
  check_and_notify()
150
151
  verbose = args.verbose
151
152
 
153
+ # Load config to check require_password setting
154
+ from porterminal.config import get_config
155
+
156
+ config = get_config()
157
+
158
+ # Handle password mode (CLI flag or config setting)
159
+ if args.password or config.security.require_password:
160
+ import getpass
161
+
162
+ try:
163
+ password = getpass.getpass("Enter password: ")
164
+ if not password:
165
+ console.print("[red]Error:[/red] Password cannot be empty")
166
+ return 1
167
+
168
+ import bcrypt
169
+
170
+ password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt())
171
+ os.environ["PORTERMINAL_PASSWORD_HASH"] = password_hash.decode()
172
+ console.print("[green]Password protection enabled[/green]")
173
+ except KeyboardInterrupt:
174
+ console.print("\n[dim]Cancelled[/dim]")
175
+ return 0
176
+
152
177
  # Handle background mode
153
178
  if args.background:
154
179
  return _run_in_background(args)
@@ -170,9 +195,6 @@ def main() -> int:
170
195
  cwd_str = str(cwd)
171
196
  os.environ["PORTERMINAL_CWD"] = cwd_str
172
197
 
173
- from porterminal.config import get_config
174
-
175
- config = get_config()
176
198
  bind_host = config.server.host
177
199
  preferred_port = config.server.port
178
200
  port = preferred_port
@@ -226,6 +248,10 @@ def main() -> int:
226
248
  status.update("[cyan]Establishing tunnel...[/cyan]")
227
249
  tunnel_process, tunnel_url = start_cloudflared(port)
228
250
 
251
+ if tunnel_url:
252
+ # Wait for tunnel to stabilize before showing URL
253
+ time.sleep(1)
254
+
229
255
  if not tunnel_url:
230
256
  console.print("[red]Error:[/red] Failed to establish tunnel")
231
257
  for proc in [server_process, tunnel_process]:
@@ -314,11 +340,14 @@ def main() -> int:
314
340
  proc.kill()
315
341
  proc.wait()
316
342
 
317
- cleanup_process(server_process, "server")
318
- cleanup_process(tunnel_process, "tunnel")
319
-
320
- # Give processes time to release the port
321
- time.sleep(0.5)
343
+ # Ignore Ctrl+C during cleanup to prevent orphaned processes
344
+ # Cleanup has timeouts so it won't hang forever
345
+ old_handler = signal.signal(signal.SIGINT, signal.SIG_IGN)
346
+ try:
347
+ cleanup_process(server_process, "server")
348
+ cleanup_process(tunnel_process, "tunnel")
349
+ finally:
350
+ signal.signal(signal.SIGINT, old_handler)
322
351
 
323
352
  return 0
324
353
 
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.2.7'
32
- __version_tuple__ = version_tuple = (0, 2, 7)
31
+ __version__ = version = '0.4.2'
32
+ __version_tuple__ = version_tuple = (0, 4, 2)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -17,6 +17,7 @@ from . import __version__
17
17
  from .composition import create_container
18
18
  from .container import Container
19
19
  from .domain import UserId
20
+ from .infrastructure.auth import authenticate_connection, validate_auth_message
20
21
  from .infrastructure.web import FastAPIWebSocketAdapter
21
22
  from .logging_setup import setup_logging_from_env
22
23
 
@@ -55,7 +56,12 @@ async def lifespan(app: FastAPI):
55
56
  # config_path=None uses find_config_file() to search standard locations
56
57
  cwd = os.environ.get("PORTERMINAL_CWD")
57
58
 
58
- container = create_container(config_path=None, cwd=cwd)
59
+ # Get password hash from environment if set
60
+ password_hash = None
61
+ if hash_str := os.environ.get("PORTERMINAL_PASSWORD_HASH"):
62
+ password_hash = hash_str.encode()
63
+
64
+ container = create_container(config_path=None, cwd=cwd, password_hash=password_hash)
59
65
  app.state.container = container
60
66
 
61
67
  # Wire up cascade: when session is destroyed, close associated tabs and broadcast
@@ -225,6 +231,17 @@ def create_app() -> FastAPI:
225
231
  )
226
232
 
227
233
  try:
234
+ # Authentication phase if password is set
235
+ if container.password_hash is not None:
236
+ authenticated = await authenticate_connection(
237
+ connection,
238
+ container.password_hash,
239
+ max_attempts=container.max_auth_attempts,
240
+ )
241
+ if not authenticated:
242
+ await websocket.close(code=4001, reason="Auth failed")
243
+ return
244
+
228
245
  # Register for broadcasts
229
246
  await connection_registry.register(user_id, connection)
230
247
 
@@ -333,6 +350,13 @@ def create_app() -> FastAPI:
333
350
  session.session_id,
334
351
  )
335
352
 
353
+ # Authentication check if password is set
354
+ if container.password_hash is not None:
355
+ if not await validate_auth_message(connection, container.password_hash):
356
+ logger.warning("Terminal WebSocket auth failed user_id=%s", user_id)
357
+ await websocket.close(code=4001, reason="Auth failed")
358
+ return
359
+
336
360
  # Update tab access time
337
361
  tab_service.touch_tab(tab_id, user_id)
338
362
 
@@ -1,7 +1,9 @@
1
1
  """Application layer ports - interfaces for presentation layer."""
2
2
 
3
3
  from .connection_port import ConnectionPort
4
+ from .connection_registry_port import ConnectionRegistryPort
4
5
 
5
6
  __all__ = [
6
7
  "ConnectionPort",
8
+ "ConnectionRegistryPort",
7
9
  ]
@@ -0,0 +1,46 @@
1
+ """Connection registry port - interface for broadcasting to user connections."""
2
+
3
+ from typing import TYPE_CHECKING, Any, Protocol
4
+
5
+ if TYPE_CHECKING:
6
+ from porterminal.domain import UserId
7
+
8
+ from .connection_port import ConnectionPort
9
+
10
+
11
+ class ConnectionRegistryPort(Protocol):
12
+ """Protocol for managing and broadcasting to user connections.
13
+
14
+ Infrastructure layer (e.g., UserConnectionRegistry) implements this.
15
+ Application layer uses this interface for broadcasting messages.
16
+ """
17
+
18
+ async def register(self, user_id: "UserId", connection: "ConnectionPort") -> None:
19
+ """Register a new connection for a user."""
20
+ ...
21
+
22
+ async def unregister(self, user_id: "UserId", connection: "ConnectionPort") -> None:
23
+ """Unregister a connection."""
24
+ ...
25
+
26
+ async def broadcast(
27
+ self,
28
+ user_id: "UserId",
29
+ message: dict[str, Any],
30
+ exclude: "ConnectionPort | None" = None,
31
+ ) -> int:
32
+ """Send message to all connections for a user.
33
+
34
+ Args:
35
+ user_id: User to broadcast to.
36
+ message: Message dict to send.
37
+ exclude: Optional connection to exclude (e.g., the sender).
38
+
39
+ Returns:
40
+ Number of connections sent to.
41
+ """
42
+ ...
43
+
44
+ def total_connections(self) -> int:
45
+ """Get total number of connections across all users."""
46
+ ...
@@ -3,7 +3,7 @@
3
3
  import logging
4
4
  from collections.abc import Callable
5
5
 
6
- from porterminal.application.ports import ConnectionPort
6
+ from porterminal.application.ports import ConnectionPort, ConnectionRegistryPort
7
7
  from porterminal.application.services.session_service import SessionService
8
8
  from porterminal.application.services.tab_service import TabService
9
9
  from porterminal.domain import (
@@ -11,7 +11,6 @@ from porterminal.domain import (
11
11
  TerminalDimensions,
12
12
  UserId,
13
13
  )
14
- from porterminal.infrastructure.registry import UserConnectionRegistry
15
14
 
16
15
  logger = logging.getLogger(__name__)
17
16
 
@@ -27,7 +26,7 @@ class ManagementService:
27
26
  self,
28
27
  session_service: SessionService,
29
28
  tab_service: TabService,
30
- connection_registry: UserConnectionRegistry,
29
+ connection_registry: ConnectionRegistryPort,
31
30
  shell_provider: Callable[[str | None], ShellCommand | None],
32
31
  default_dimensions: TerminalDimensions,
33
32
  ) -> None:
@@ -37,6 +36,23 @@ class ManagementService:
37
36
  self._get_shell = shell_provider
38
37
  self._default_dims = default_dimensions
39
38
 
39
+ async def _send_error(
40
+ self,
41
+ connection: ConnectionPort,
42
+ response_type: str,
43
+ request_id: str,
44
+ error: str,
45
+ ) -> None:
46
+ """Send an error response to a connection."""
47
+ await connection.send_message(
48
+ {
49
+ "type": response_type,
50
+ "request_id": request_id,
51
+ "success": False,
52
+ "error": error,
53
+ }
54
+ )
55
+
40
56
  async def handle_message(
41
57
  self,
42
58
  user_id: UserId,
@@ -77,13 +93,8 @@ class ManagementService:
77
93
  # Get shell
78
94
  shell = self._get_shell(shell_id)
79
95
  if not shell:
80
- await connection.send_message(
81
- {
82
- "type": "create_tab_response",
83
- "request_id": request_id,
84
- "success": False,
85
- "error": "Invalid shell",
86
- }
96
+ await self._send_error(
97
+ connection, "create_tab_response", request_id, "Invalid shell"
87
98
  )
88
99
  return
89
100
 
@@ -128,14 +139,7 @@ class ManagementService:
128
139
 
129
140
  except ValueError as e:
130
141
  logger.warning("Tab creation failed: %s", e)
131
- await connection.send_message(
132
- {
133
- "type": "create_tab_response",
134
- "request_id": request_id,
135
- "success": False,
136
- "error": str(e),
137
- }
138
- )
142
+ await self._send_error(connection, "create_tab_response", request_id, str(e))
139
143
 
140
144
  async def _handle_close_tab(
141
145
  self,
@@ -148,27 +152,13 @@ class ManagementService:
148
152
  tab_id = message.get("tab_id")
149
153
 
150
154
  if not tab_id:
151
- await connection.send_message(
152
- {
153
- "type": "close_tab_response",
154
- "request_id": request_id,
155
- "success": False,
156
- "error": "Missing tab_id",
157
- }
158
- )
155
+ await self._send_error(connection, "close_tab_response", request_id, "Missing tab_id")
159
156
  return
160
157
 
161
158
  # Get tab and session info before closing
162
159
  tab = self._tab_service.get_tab(tab_id)
163
160
  if not tab:
164
- await connection.send_message(
165
- {
166
- "type": "close_tab_response",
167
- "request_id": request_id,
168
- "success": False,
169
- "error": "Tab not found",
170
- }
171
- )
161
+ await self._send_error(connection, "close_tab_response", request_id, "Tab not found")
172
162
  return
173
163
 
174
164
  session_id = tab.session_id
@@ -176,13 +166,8 @@ class ManagementService:
176
166
  # Close the tab
177
167
  closed_tab = self._tab_service.close_tab(tab_id, user_id)
178
168
  if not closed_tab:
179
- await connection.send_message(
180
- {
181
- "type": "close_tab_response",
182
- "request_id": request_id,
183
- "success": False,
184
- "error": "Failed to close tab",
185
- }
169
+ await self._send_error(
170
+ connection, "close_tab_response", request_id, "Failed to close tab"
186
171
  )
187
172
  return
188
173
 
@@ -223,26 +208,16 @@ class ManagementService:
223
208
  new_name = message.get("name")
224
209
 
225
210
  if not tab_id or not new_name:
226
- await connection.send_message(
227
- {
228
- "type": "rename_tab_response",
229
- "request_id": request_id,
230
- "success": False,
231
- "error": "Missing tab_id or name",
232
- }
211
+ await self._send_error(
212
+ connection, "rename_tab_response", request_id, "Missing tab_id or name"
233
213
  )
234
214
  return
235
215
 
236
216
  # Rename the tab
237
217
  tab = self._tab_service.rename_tab(tab_id, user_id, new_name)
238
218
  if not tab:
239
- await connection.send_message(
240
- {
241
- "type": "rename_tab_response",
242
- "request_id": request_id,
243
- "success": False,
244
- "error": "Failed to rename tab",
245
- }
219
+ await self._send_error(
220
+ connection, "rename_tab_response", request_id, "Failed to rename tab"
246
221
  )
247
222
  return
248
223
 
@@ -2,14 +2,11 @@
2
2
 
3
3
  import asyncio
4
4
  import logging
5
- import os
6
5
  import uuid
7
6
  from collections.abc import Awaitable, Callable
8
7
  from datetime import UTC, datetime
9
8
 
10
9
  from porterminal.domain import (
11
- EnvironmentRules,
12
- EnvironmentSanitizer,
13
10
  PTYPort,
14
11
  Session,
15
12
  SessionId,
@@ -32,17 +29,13 @@ class SessionService:
32
29
  def __init__(
33
30
  self,
34
31
  repository: SessionRepository[PTYPort],
35
- pty_factory: Callable[
36
- [ShellCommand, TerminalDimensions, dict[str, str], str | None], PTYPort
37
- ],
32
+ pty_factory: Callable[[ShellCommand, TerminalDimensions, str | None], PTYPort],
38
33
  limit_checker: SessionLimitChecker | None = None,
39
- environment_sanitizer: EnvironmentSanitizer | None = None,
40
34
  working_directory: str | None = None,
41
35
  ) -> None:
42
36
  self._repository = repository
43
37
  self._pty_factory = pty_factory
44
38
  self._limit_checker = limit_checker or SessionLimitChecker()
45
- self._sanitizer = environment_sanitizer or EnvironmentSanitizer(EnvironmentRules())
46
39
  self._cwd = working_directory
47
40
  self._running = False
48
41
  self._cleanup_task: asyncio.Task | None = None
@@ -108,9 +101,8 @@ class SessionService:
108
101
  if not limit_result.allowed:
109
102
  raise ValueError(limit_result.reason)
110
103
 
111
- # Create PTY with sanitized environment
112
- env = self._sanitizer.sanitize(dict(os.environ))
113
- pty = self._pty_factory(shell, dimensions, env, self._cwd)
104
+ # Create PTY (environment sanitization handled by PTY layer)
105
+ pty = self._pty_factory(shell, dimensions, self._cwd)
114
106
 
115
107
  # Create session (starts with 0 clients, caller adds via add_client())
116
108
  now = datetime.now(UTC)