ptn 0.1.4__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.
- porterminal/__init__.py +288 -0
- porterminal/__main__.py +8 -0
- porterminal/app.py +381 -0
- porterminal/application/__init__.py +1 -0
- porterminal/application/ports/__init__.py +7 -0
- porterminal/application/ports/connection_port.py +34 -0
- porterminal/application/services/__init__.py +13 -0
- porterminal/application/services/management_service.py +279 -0
- porterminal/application/services/session_service.py +249 -0
- porterminal/application/services/tab_service.py +286 -0
- porterminal/application/services/terminal_service.py +426 -0
- porterminal/asgi.py +38 -0
- porterminal/cli/__init__.py +19 -0
- porterminal/cli/args.py +91 -0
- porterminal/cli/display.py +157 -0
- porterminal/composition.py +208 -0
- porterminal/config.py +195 -0
- porterminal/container.py +65 -0
- porterminal/domain/__init__.py +91 -0
- porterminal/domain/entities/__init__.py +16 -0
- porterminal/domain/entities/output_buffer.py +73 -0
- porterminal/domain/entities/session.py +86 -0
- porterminal/domain/entities/tab.py +71 -0
- porterminal/domain/ports/__init__.py +12 -0
- porterminal/domain/ports/pty_port.py +80 -0
- porterminal/domain/ports/session_repository.py +58 -0
- porterminal/domain/ports/tab_repository.py +75 -0
- porterminal/domain/services/__init__.py +18 -0
- porterminal/domain/services/environment_sanitizer.py +61 -0
- porterminal/domain/services/rate_limiter.py +63 -0
- porterminal/domain/services/session_limits.py +104 -0
- porterminal/domain/services/tab_limits.py +54 -0
- porterminal/domain/values/__init__.py +25 -0
- porterminal/domain/values/environment_rules.py +156 -0
- porterminal/domain/values/rate_limit_config.py +21 -0
- porterminal/domain/values/session_id.py +20 -0
- porterminal/domain/values/shell_command.py +37 -0
- porterminal/domain/values/tab_id.py +24 -0
- porterminal/domain/values/terminal_dimensions.py +45 -0
- porterminal/domain/values/user_id.py +25 -0
- porterminal/infrastructure/__init__.py +20 -0
- porterminal/infrastructure/cloudflared.py +295 -0
- porterminal/infrastructure/config/__init__.py +9 -0
- porterminal/infrastructure/config/shell_detector.py +84 -0
- porterminal/infrastructure/config/yaml_loader.py +34 -0
- porterminal/infrastructure/network.py +43 -0
- porterminal/infrastructure/registry/__init__.py +5 -0
- porterminal/infrastructure/registry/user_connection_registry.py +104 -0
- porterminal/infrastructure/repositories/__init__.py +9 -0
- porterminal/infrastructure/repositories/in_memory_session.py +70 -0
- porterminal/infrastructure/repositories/in_memory_tab.py +124 -0
- porterminal/infrastructure/server.py +161 -0
- porterminal/infrastructure/web/__init__.py +7 -0
- porterminal/infrastructure/web/websocket_adapter.py +78 -0
- porterminal/logging_setup.py +48 -0
- porterminal/pty/__init__.py +46 -0
- porterminal/pty/env.py +97 -0
- porterminal/pty/manager.py +163 -0
- porterminal/pty/protocol.py +84 -0
- porterminal/pty/unix.py +162 -0
- porterminal/pty/windows.py +131 -0
- porterminal/static/assets/app-BQiuUo6Q.css +32 -0
- porterminal/static/assets/app-YNN_jEhv.js +71 -0
- porterminal/static/icon.svg +34 -0
- porterminal/static/index.html +139 -0
- porterminal/static/manifest.json +31 -0
- porterminal/static/sw.js +66 -0
- porterminal/updater.py +257 -0
- ptn-0.1.4.dist-info/METADATA +191 -0
- ptn-0.1.4.dist-info/RECORD +73 -0
- ptn-0.1.4.dist-info/WHEEL +4 -0
- ptn-0.1.4.dist-info/entry_points.txt +2 -0
- ptn-0.1.4.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Session repository port - interface for session storage."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import TypeVar
|
|
5
|
+
|
|
6
|
+
from ..entities.session import Session
|
|
7
|
+
from ..values.session_id import SessionId
|
|
8
|
+
from ..values.user_id import UserId
|
|
9
|
+
|
|
10
|
+
PTYHandle = TypeVar("PTYHandle")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SessionRepository[PTYHandle](ABC):
|
|
14
|
+
"""Abstract interface for session storage.
|
|
15
|
+
|
|
16
|
+
Infrastructure layer provides concrete implementation.
|
|
17
|
+
Methods are synchronous - async wrapping is infrastructure concern.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def get(self, session_id: SessionId) -> Session[PTYHandle] | None:
|
|
22
|
+
"""Get session by ID."""
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def get_by_id_str(self, session_id: str) -> Session[PTYHandle] | None:
|
|
27
|
+
"""Get session by ID string (convenience method)."""
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def get_by_user(self, user_id: UserId) -> list[Session[PTYHandle]]:
|
|
32
|
+
"""Get all sessions for a user."""
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def add(self, session: Session[PTYHandle]) -> None:
|
|
37
|
+
"""Add a new session."""
|
|
38
|
+
...
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def remove(self, session_id: SessionId) -> Session[PTYHandle] | None:
|
|
42
|
+
"""Remove and return a session."""
|
|
43
|
+
...
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def count(self) -> int:
|
|
47
|
+
"""Get total session count."""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def count_for_user(self, user_id: UserId) -> int:
|
|
52
|
+
"""Get session count for a user."""
|
|
53
|
+
...
|
|
54
|
+
|
|
55
|
+
@abstractmethod
|
|
56
|
+
def all_sessions(self) -> list[Session[PTYHandle]]:
|
|
57
|
+
"""Get all sessions (for cleanup iteration)."""
|
|
58
|
+
...
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Tab repository port - interface for tab storage."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
from ..entities.tab import Tab
|
|
6
|
+
from ..values.session_id import SessionId
|
|
7
|
+
from ..values.tab_id import TabId
|
|
8
|
+
from ..values.user_id import UserId
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TabRepository(ABC):
|
|
12
|
+
"""Abstract interface for tab storage.
|
|
13
|
+
|
|
14
|
+
Infrastructure layer provides concrete implementation.
|
|
15
|
+
Methods are synchronous - async wrapping is infrastructure concern.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def get(self, tab_id: TabId) -> Tab | None:
|
|
20
|
+
"""Get tab by ID."""
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def get_by_id_str(self, tab_id: str) -> Tab | None:
|
|
25
|
+
"""Get tab by ID string (convenience method)."""
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def get_by_user(self, user_id: UserId) -> list[Tab]:
|
|
30
|
+
"""Get all tabs for a user (ordered by last_accessed DESC)."""
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def get_by_session(self, session_id: SessionId) -> list[Tab]:
|
|
35
|
+
"""Get all tabs referencing a specific session."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def add(self, tab: Tab) -> None:
|
|
40
|
+
"""Add a new tab."""
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def update(self, tab: Tab) -> None:
|
|
45
|
+
"""Update an existing tab (name, last_accessed)."""
|
|
46
|
+
...
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def remove(self, tab_id: TabId) -> Tab | None:
|
|
50
|
+
"""Remove and return a tab."""
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def remove_by_session(self, session_id: SessionId) -> list[Tab]:
|
|
55
|
+
"""Remove all tabs referencing a session (cascade).
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
List of removed tabs.
|
|
59
|
+
"""
|
|
60
|
+
...
|
|
61
|
+
|
|
62
|
+
@abstractmethod
|
|
63
|
+
def count(self) -> int:
|
|
64
|
+
"""Get total tab count."""
|
|
65
|
+
...
|
|
66
|
+
|
|
67
|
+
@abstractmethod
|
|
68
|
+
def count_for_user(self, user_id: UserId) -> int:
|
|
69
|
+
"""Get tab count for a user."""
|
|
70
|
+
...
|
|
71
|
+
|
|
72
|
+
@abstractmethod
|
|
73
|
+
def all_tabs(self) -> list[Tab]:
|
|
74
|
+
"""Get all tabs (for cleanup iteration)."""
|
|
75
|
+
...
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Domain services - pure business logic operations."""
|
|
2
|
+
|
|
3
|
+
from .environment_sanitizer import EnvironmentSanitizer
|
|
4
|
+
from .rate_limiter import Clock, TokenBucketRateLimiter
|
|
5
|
+
from .session_limits import SessionLimitChecker, SessionLimitConfig, SessionLimitResult
|
|
6
|
+
from .tab_limits import TabLimitChecker, TabLimitConfig, TabLimitResult
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"TokenBucketRateLimiter",
|
|
10
|
+
"Clock",
|
|
11
|
+
"EnvironmentSanitizer",
|
|
12
|
+
"SessionLimitChecker",
|
|
13
|
+
"SessionLimitConfig",
|
|
14
|
+
"SessionLimitResult",
|
|
15
|
+
"TabLimitChecker",
|
|
16
|
+
"TabLimitConfig",
|
|
17
|
+
"TabLimitResult",
|
|
18
|
+
]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Pure environment sanitization service."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from ..values.environment_rules import EnvironmentRules
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class EnvironmentSanitizer:
|
|
10
|
+
"""Pure environment sanitization service.
|
|
11
|
+
|
|
12
|
+
Takes environment dict as input (does NOT access os.environ).
|
|
13
|
+
Infrastructure layer is responsible for providing the input.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
rules: EnvironmentRules
|
|
17
|
+
|
|
18
|
+
def sanitize(self, source_env: dict[str, str]) -> dict[str, str]:
|
|
19
|
+
"""Sanitize environment variables according to rules.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
source_env: Source environment (e.g., from os.environ).
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
Sanitized environment dict.
|
|
26
|
+
"""
|
|
27
|
+
result: dict[str, str] = {}
|
|
28
|
+
|
|
29
|
+
# Copy only allowed variables
|
|
30
|
+
for var in self.rules.allowed_vars:
|
|
31
|
+
if var in source_env:
|
|
32
|
+
result[var] = source_env[var]
|
|
33
|
+
|
|
34
|
+
# Defense in depth: remove any blocked vars that might have slipped through
|
|
35
|
+
for var in self.rules.blocked_vars:
|
|
36
|
+
result.pop(var, None)
|
|
37
|
+
|
|
38
|
+
# Also remove any vars that match blocked patterns
|
|
39
|
+
blocked_suffixes = ("_KEY", "_SECRET", "_TOKEN", "_PASSWORD", "_CREDENTIAL")
|
|
40
|
+
for var in list(result.keys()):
|
|
41
|
+
if any(var.endswith(suffix) for suffix in blocked_suffixes):
|
|
42
|
+
del result[var]
|
|
43
|
+
|
|
44
|
+
# Apply forced variables
|
|
45
|
+
result.update(self.rules.get_forced_vars_dict())
|
|
46
|
+
|
|
47
|
+
return result
|
|
48
|
+
|
|
49
|
+
def is_var_allowed(self, var_name: str) -> bool:
|
|
50
|
+
"""Check if a variable name is allowed."""
|
|
51
|
+
if var_name in self.rules.blocked_vars:
|
|
52
|
+
return False
|
|
53
|
+
return var_name in self.rules.allowed_vars
|
|
54
|
+
|
|
55
|
+
def is_var_blocked(self, var_name: str) -> bool:
|
|
56
|
+
"""Check if a variable name is blocked."""
|
|
57
|
+
if var_name in self.rules.blocked_vars:
|
|
58
|
+
return True
|
|
59
|
+
|
|
60
|
+
blocked_suffixes = ("_KEY", "_SECRET", "_TOKEN", "_PASSWORD", "_CREDENTIAL")
|
|
61
|
+
return any(var_name.endswith(suffix) for suffix in blocked_suffixes)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Pure token bucket rate limiter - synchronous, no asyncio dependency."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from ..values.rate_limit_config import RateLimitConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Clock(Protocol):
|
|
10
|
+
"""Protocol for time source (dependency injection)."""
|
|
11
|
+
|
|
12
|
+
def now(self) -> float:
|
|
13
|
+
"""Return current time in seconds (monotonic)."""
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class TokenBucketRateLimiter:
|
|
19
|
+
"""Pure token bucket rate limiter.
|
|
20
|
+
|
|
21
|
+
Synchronous, no asyncio dependency.
|
|
22
|
+
Time source is injected for testability.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
config: RateLimitConfig
|
|
26
|
+
clock: Clock
|
|
27
|
+
_tokens: float = field(init=False)
|
|
28
|
+
_last_update: float = field(init=False)
|
|
29
|
+
|
|
30
|
+
def __post_init__(self) -> None:
|
|
31
|
+
self._tokens = float(self.config.burst)
|
|
32
|
+
self._last_update = self.clock.now()
|
|
33
|
+
|
|
34
|
+
def try_acquire(self, tokens: int = 1) -> bool:
|
|
35
|
+
"""Try to acquire tokens.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
tokens: Number of tokens to acquire.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
True if tokens were acquired, False if rate limited.
|
|
42
|
+
"""
|
|
43
|
+
now = self.clock.now()
|
|
44
|
+
elapsed = now - self._last_update
|
|
45
|
+
|
|
46
|
+
# Refill bucket
|
|
47
|
+
self._tokens = min(self.config.burst, self._tokens + elapsed * self.config.rate)
|
|
48
|
+
self._last_update = now
|
|
49
|
+
|
|
50
|
+
if self._tokens >= tokens:
|
|
51
|
+
self._tokens -= tokens
|
|
52
|
+
return True
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def available_tokens(self) -> float:
|
|
57
|
+
"""Get current available tokens (for monitoring)."""
|
|
58
|
+
return self._tokens
|
|
59
|
+
|
|
60
|
+
def reset(self) -> None:
|
|
61
|
+
"""Reset the rate limiter to full capacity."""
|
|
62
|
+
self._tokens = float(self.config.burst)
|
|
63
|
+
self._last_update = self.clock.now()
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Session limit checking service - pure business logic."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import TypeVar
|
|
6
|
+
|
|
7
|
+
from ..entities.session import (
|
|
8
|
+
MAX_SESSIONS_PER_USER,
|
|
9
|
+
MAX_TOTAL_SESSIONS,
|
|
10
|
+
RECONNECT_WINDOW_SECONDS,
|
|
11
|
+
SESSION_MAX_DURATION_SECONDS,
|
|
12
|
+
Session,
|
|
13
|
+
)
|
|
14
|
+
from ..values.user_id import UserId
|
|
15
|
+
|
|
16
|
+
PTYHandle = TypeVar("PTYHandle")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class SessionLimitConfig:
|
|
21
|
+
"""Session limit configuration."""
|
|
22
|
+
|
|
23
|
+
max_per_user: int = MAX_SESSIONS_PER_USER
|
|
24
|
+
max_total: int = MAX_TOTAL_SESSIONS
|
|
25
|
+
max_duration_seconds: int = SESSION_MAX_DURATION_SECONDS
|
|
26
|
+
reconnect_window_seconds: int = RECONNECT_WINDOW_SECONDS
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class SessionLimitResult:
|
|
31
|
+
"""Result of session limit check."""
|
|
32
|
+
|
|
33
|
+
allowed: bool
|
|
34
|
+
reason: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SessionLimitChecker:
|
|
38
|
+
"""Check session limits (pure business logic)."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, config: SessionLimitConfig | None = None) -> None:
|
|
41
|
+
self.config = config or SessionLimitConfig()
|
|
42
|
+
|
|
43
|
+
def can_create_session(
|
|
44
|
+
self,
|
|
45
|
+
user_id: UserId,
|
|
46
|
+
user_session_count: int,
|
|
47
|
+
total_session_count: int,
|
|
48
|
+
) -> SessionLimitResult:
|
|
49
|
+
"""Check if a new session can be created."""
|
|
50
|
+
if user_session_count >= self.config.max_per_user:
|
|
51
|
+
return SessionLimitResult(
|
|
52
|
+
allowed=False,
|
|
53
|
+
reason=f"Maximum sessions ({self.config.max_per_user}) reached for user",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if total_session_count >= self.config.max_total:
|
|
57
|
+
return SessionLimitResult(
|
|
58
|
+
allowed=False,
|
|
59
|
+
reason="Server session limit reached",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
return SessionLimitResult(allowed=True)
|
|
63
|
+
|
|
64
|
+
def can_reconnect(
|
|
65
|
+
self,
|
|
66
|
+
session: Session,
|
|
67
|
+
requesting_user: UserId,
|
|
68
|
+
) -> SessionLimitResult:
|
|
69
|
+
"""Check if user can reconnect to session."""
|
|
70
|
+
if session.user_id != requesting_user:
|
|
71
|
+
return SessionLimitResult(
|
|
72
|
+
allowed=False,
|
|
73
|
+
reason="Session belongs to another user",
|
|
74
|
+
)
|
|
75
|
+
return SessionLimitResult(allowed=True)
|
|
76
|
+
|
|
77
|
+
def should_cleanup_session(
|
|
78
|
+
self,
|
|
79
|
+
session: Session,
|
|
80
|
+
now: datetime,
|
|
81
|
+
is_pty_alive: bool,
|
|
82
|
+
) -> tuple[bool, str | None]:
|
|
83
|
+
"""Check if a session should be cleaned up.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Tuple of (should_cleanup, reason).
|
|
87
|
+
"""
|
|
88
|
+
# Primary check: PTY dead = session dead
|
|
89
|
+
if not is_pty_alive:
|
|
90
|
+
return True, "PTY died"
|
|
91
|
+
|
|
92
|
+
# Check max duration (0 = no limit)
|
|
93
|
+
if self.config.max_duration_seconds > 0:
|
|
94
|
+
age = (now - session.created_at).total_seconds()
|
|
95
|
+
if age > self.config.max_duration_seconds:
|
|
96
|
+
return True, "Exceeded max duration"
|
|
97
|
+
|
|
98
|
+
# Check reconnection window (0 = no limit, only for disconnected sessions)
|
|
99
|
+
if self.config.reconnect_window_seconds > 0 and not session.is_connected:
|
|
100
|
+
idle = (now - session.last_activity).total_seconds()
|
|
101
|
+
if idle > self.config.reconnect_window_seconds:
|
|
102
|
+
return True, "Reconnection window expired"
|
|
103
|
+
|
|
104
|
+
return False, None
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Tab limit checking service - pure business logic."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from ..entities.tab import MAX_TABS_PER_USER, Tab
|
|
6
|
+
from ..values.user_id import UserId
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class TabLimitConfig:
|
|
11
|
+
"""Tab limit configuration."""
|
|
12
|
+
|
|
13
|
+
max_per_user: int = MAX_TABS_PER_USER
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class TabLimitResult:
|
|
18
|
+
"""Result of tab limit check."""
|
|
19
|
+
|
|
20
|
+
allowed: bool
|
|
21
|
+
reason: str | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TabLimitChecker:
|
|
25
|
+
"""Check tab limits (pure business logic)."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, config: TabLimitConfig | None = None) -> None:
|
|
28
|
+
self.config = config or TabLimitConfig()
|
|
29
|
+
|
|
30
|
+
def can_create_tab(
|
|
31
|
+
self,
|
|
32
|
+
user_id: UserId,
|
|
33
|
+
user_tab_count: int,
|
|
34
|
+
) -> TabLimitResult:
|
|
35
|
+
"""Check if a new tab can be created for user."""
|
|
36
|
+
if user_tab_count >= self.config.max_per_user:
|
|
37
|
+
return TabLimitResult(
|
|
38
|
+
allowed=False,
|
|
39
|
+
reason=f"Maximum tabs ({self.config.max_per_user}) reached",
|
|
40
|
+
)
|
|
41
|
+
return TabLimitResult(allowed=True)
|
|
42
|
+
|
|
43
|
+
def can_access_tab(
|
|
44
|
+
self,
|
|
45
|
+
tab: Tab,
|
|
46
|
+
requesting_user: UserId,
|
|
47
|
+
) -> TabLimitResult:
|
|
48
|
+
"""Check if user can access a tab."""
|
|
49
|
+
if tab.user_id != requesting_user:
|
|
50
|
+
return TabLimitResult(
|
|
51
|
+
allowed=False,
|
|
52
|
+
reason="Tab belongs to another user",
|
|
53
|
+
)
|
|
54
|
+
return TabLimitResult(allowed=True)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Domain value objects - immutable data structures."""
|
|
2
|
+
|
|
3
|
+
from .environment_rules import DEFAULT_BLOCKED_VARS, DEFAULT_SAFE_VARS, EnvironmentRules
|
|
4
|
+
from .rate_limit_config import RateLimitConfig
|
|
5
|
+
from .session_id import SessionId
|
|
6
|
+
from .shell_command import ShellCommand
|
|
7
|
+
from .tab_id import TabId
|
|
8
|
+
from .terminal_dimensions import MAX_COLS, MAX_ROWS, MIN_COLS, MIN_ROWS, TerminalDimensions
|
|
9
|
+
from .user_id import UserId
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"TerminalDimensions",
|
|
13
|
+
"MIN_COLS",
|
|
14
|
+
"MAX_COLS",
|
|
15
|
+
"MIN_ROWS",
|
|
16
|
+
"MAX_ROWS",
|
|
17
|
+
"SessionId",
|
|
18
|
+
"UserId",
|
|
19
|
+
"TabId",
|
|
20
|
+
"ShellCommand",
|
|
21
|
+
"RateLimitConfig",
|
|
22
|
+
"EnvironmentRules",
|
|
23
|
+
"DEFAULT_SAFE_VARS",
|
|
24
|
+
"DEFAULT_BLOCKED_VARS",
|
|
25
|
+
]
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Environment sanitization rules value object."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
# These are the business rules for environment sanitization
|
|
6
|
+
# Extracted from pty/env.py - pure data, no I/O
|
|
7
|
+
|
|
8
|
+
DEFAULT_SAFE_VARS: frozenset[str] = frozenset(
|
|
9
|
+
{
|
|
10
|
+
# System paths
|
|
11
|
+
"PATH",
|
|
12
|
+
"PATHEXT",
|
|
13
|
+
"SYSTEMROOT",
|
|
14
|
+
"WINDIR",
|
|
15
|
+
"TEMP",
|
|
16
|
+
"TMP",
|
|
17
|
+
"COMSPEC",
|
|
18
|
+
# User directories
|
|
19
|
+
"HOME",
|
|
20
|
+
"USERPROFILE",
|
|
21
|
+
"HOMEDRIVE",
|
|
22
|
+
"HOMEPATH",
|
|
23
|
+
"LOCALAPPDATA",
|
|
24
|
+
"APPDATA",
|
|
25
|
+
"PROGRAMFILES",
|
|
26
|
+
"PROGRAMFILES(X86)",
|
|
27
|
+
"COMMONPROGRAMFILES",
|
|
28
|
+
# System info
|
|
29
|
+
"COMPUTERNAME",
|
|
30
|
+
"USERNAME",
|
|
31
|
+
"USERDOMAIN",
|
|
32
|
+
"OS",
|
|
33
|
+
"PROCESSOR_ARCHITECTURE",
|
|
34
|
+
"NUMBER_OF_PROCESSORS",
|
|
35
|
+
# Terminal
|
|
36
|
+
"TERM",
|
|
37
|
+
"COLORTERM",
|
|
38
|
+
"TERM_PROGRAM",
|
|
39
|
+
# Locale
|
|
40
|
+
"LANG",
|
|
41
|
+
"LC_ALL",
|
|
42
|
+
"LC_CTYPE",
|
|
43
|
+
# Shell-specific
|
|
44
|
+
"SHELL",
|
|
45
|
+
"SHLVL",
|
|
46
|
+
"PWD",
|
|
47
|
+
"OLDPWD",
|
|
48
|
+
# Editor
|
|
49
|
+
"EDITOR",
|
|
50
|
+
"VISUAL",
|
|
51
|
+
# Display (for X11 forwarding if needed)
|
|
52
|
+
"DISPLAY",
|
|
53
|
+
# SSH (non-sensitive)
|
|
54
|
+
"SSH_TTY",
|
|
55
|
+
"SSH_CONNECTION",
|
|
56
|
+
# XDG directories
|
|
57
|
+
"XDG_CONFIG_HOME",
|
|
58
|
+
"XDG_DATA_HOME",
|
|
59
|
+
"XDG_CACHE_HOME",
|
|
60
|
+
"XDG_RUNTIME_DIR",
|
|
61
|
+
# Python
|
|
62
|
+
"PYTHONPATH",
|
|
63
|
+
"VIRTUAL_ENV",
|
|
64
|
+
# Node
|
|
65
|
+
"NODE_PATH",
|
|
66
|
+
# Go
|
|
67
|
+
"GOPATH",
|
|
68
|
+
# Rust
|
|
69
|
+
"CARGO_HOME",
|
|
70
|
+
"RUSTUP_HOME",
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
DEFAULT_BLOCKED_VARS: frozenset[str] = frozenset(
|
|
75
|
+
{
|
|
76
|
+
# AWS
|
|
77
|
+
"AWS_ACCESS_KEY_ID",
|
|
78
|
+
"AWS_SECRET_ACCESS_KEY",
|
|
79
|
+
"AWS_SESSION_TOKEN",
|
|
80
|
+
"AWS_SECURITY_TOKEN",
|
|
81
|
+
# Azure
|
|
82
|
+
"AZURE_CLIENT_SECRET",
|
|
83
|
+
"AZURE_CLIENT_ID",
|
|
84
|
+
"AZURE_TENANT_ID",
|
|
85
|
+
# GCP
|
|
86
|
+
"GOOGLE_APPLICATION_CREDENTIALS",
|
|
87
|
+
"CLOUDSDK_AUTH_ACCESS_TOKEN",
|
|
88
|
+
# Git/GitHub/GitLab
|
|
89
|
+
"GH_TOKEN",
|
|
90
|
+
"GITHUB_TOKEN",
|
|
91
|
+
"GITLAB_TOKEN",
|
|
92
|
+
"GIT_ASKPASS",
|
|
93
|
+
"GIT_CREDENTIALS",
|
|
94
|
+
# Package managers
|
|
95
|
+
"NPM_TOKEN",
|
|
96
|
+
"PYPI_TOKEN",
|
|
97
|
+
"RUBYGEMS_API_KEY",
|
|
98
|
+
# AI APIs
|
|
99
|
+
"ANTHROPIC_API_KEY",
|
|
100
|
+
"OPENAI_API_KEY",
|
|
101
|
+
"GOOGLE_API_KEY",
|
|
102
|
+
"COHERE_API_KEY",
|
|
103
|
+
# Payment
|
|
104
|
+
"STRIPE_SECRET_KEY",
|
|
105
|
+
"STRIPE_API_KEY",
|
|
106
|
+
# Database
|
|
107
|
+
"DATABASE_URL",
|
|
108
|
+
"DB_PASSWORD",
|
|
109
|
+
"MYSQL_PASSWORD",
|
|
110
|
+
"POSTGRES_PASSWORD",
|
|
111
|
+
"REDIS_PASSWORD",
|
|
112
|
+
"MONGODB_URI",
|
|
113
|
+
# Generic secrets
|
|
114
|
+
"SECRET_KEY",
|
|
115
|
+
"API_KEY",
|
|
116
|
+
"API_SECRET",
|
|
117
|
+
"PRIVATE_KEY",
|
|
118
|
+
"ACCESS_TOKEN",
|
|
119
|
+
"REFRESH_TOKEN",
|
|
120
|
+
"AUTH_TOKEN",
|
|
121
|
+
"JWT_SECRET",
|
|
122
|
+
"ENCRYPTION_KEY",
|
|
123
|
+
# SSH keys (if exposed via env)
|
|
124
|
+
"SSH_PRIVATE_KEY",
|
|
125
|
+
"SSH_KEY",
|
|
126
|
+
# Misc services
|
|
127
|
+
"SLACK_TOKEN",
|
|
128
|
+
"DISCORD_TOKEN",
|
|
129
|
+
"TELEGRAM_TOKEN",
|
|
130
|
+
"TWILIO_AUTH_TOKEN",
|
|
131
|
+
"SENDGRID_API_KEY",
|
|
132
|
+
"MAILGUN_API_KEY",
|
|
133
|
+
}
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True)
|
|
138
|
+
class EnvironmentRules:
|
|
139
|
+
"""Rules for environment variable sanitization."""
|
|
140
|
+
|
|
141
|
+
allowed_vars: frozenset[str] = field(default_factory=lambda: DEFAULT_SAFE_VARS)
|
|
142
|
+
blocked_vars: frozenset[str] = field(default_factory=lambda: DEFAULT_BLOCKED_VARS)
|
|
143
|
+
forced_vars: tuple[tuple[str, str], ...] = (
|
|
144
|
+
("TERM", "xterm-256color"),
|
|
145
|
+
("TERM_SESSION_TYPE", "remote-web"),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def __post_init__(self) -> None:
|
|
149
|
+
# Ensure blocked vars are not in allowed vars
|
|
150
|
+
overlap = self.allowed_vars & self.blocked_vars
|
|
151
|
+
if overlap:
|
|
152
|
+
raise ValueError(f"Vars cannot be both allowed and blocked: {overlap}")
|
|
153
|
+
|
|
154
|
+
def get_forced_vars_dict(self) -> dict[str, str]:
|
|
155
|
+
"""Get forced variables as a dictionary."""
|
|
156
|
+
return dict(self.forced_vars)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Rate limit configuration value object."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
# Default business rules
|
|
6
|
+
DEFAULT_RATE = 100.0 # tokens per second
|
|
7
|
+
DEFAULT_BURST = 500
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class RateLimitConfig:
|
|
12
|
+
"""Rate limiting configuration (value object)."""
|
|
13
|
+
|
|
14
|
+
rate: float = DEFAULT_RATE
|
|
15
|
+
burst: int = DEFAULT_BURST
|
|
16
|
+
|
|
17
|
+
def __post_init__(self) -> None:
|
|
18
|
+
if self.rate <= 0:
|
|
19
|
+
raise ValueError("Rate must be positive")
|
|
20
|
+
if self.burst <= 0:
|
|
21
|
+
raise ValueError("Burst must be positive")
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Session ID value object."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True, slots=True)
|
|
7
|
+
class SessionId:
|
|
8
|
+
"""Strongly-typed session identifier."""
|
|
9
|
+
|
|
10
|
+
value: str
|
|
11
|
+
|
|
12
|
+
def __post_init__(self) -> None:
|
|
13
|
+
if not self.value or not isinstance(self.value, str):
|
|
14
|
+
raise ValueError("SessionId cannot be empty")
|
|
15
|
+
|
|
16
|
+
def __str__(self) -> str:
|
|
17
|
+
return self.value
|
|
18
|
+
|
|
19
|
+
def __hash__(self) -> int:
|
|
20
|
+
return hash(self.value)
|