ccproxy-api 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.
- ccproxy/__init__.py +4 -0
- ccproxy/__main__.py +7 -0
- ccproxy/_version.py +21 -0
- ccproxy/adapters/__init__.py +11 -0
- ccproxy/adapters/base.py +80 -0
- ccproxy/adapters/openai/__init__.py +43 -0
- ccproxy/adapters/openai/adapter.py +915 -0
- ccproxy/adapters/openai/models.py +412 -0
- ccproxy/adapters/openai/streaming.py +449 -0
- ccproxy/api/__init__.py +28 -0
- ccproxy/api/app.py +225 -0
- ccproxy/api/dependencies.py +140 -0
- ccproxy/api/middleware/__init__.py +11 -0
- ccproxy/api/middleware/auth.py +0 -0
- ccproxy/api/middleware/cors.py +55 -0
- ccproxy/api/middleware/errors.py +703 -0
- ccproxy/api/middleware/headers.py +51 -0
- ccproxy/api/middleware/logging.py +175 -0
- ccproxy/api/middleware/request_id.py +69 -0
- ccproxy/api/middleware/server_header.py +62 -0
- ccproxy/api/responses.py +84 -0
- ccproxy/api/routes/__init__.py +16 -0
- ccproxy/api/routes/claude.py +181 -0
- ccproxy/api/routes/health.py +489 -0
- ccproxy/api/routes/metrics.py +1033 -0
- ccproxy/api/routes/proxy.py +238 -0
- ccproxy/auth/__init__.py +75 -0
- ccproxy/auth/bearer.py +68 -0
- ccproxy/auth/credentials_adapter.py +93 -0
- ccproxy/auth/dependencies.py +229 -0
- ccproxy/auth/exceptions.py +79 -0
- ccproxy/auth/manager.py +102 -0
- ccproxy/auth/models.py +118 -0
- ccproxy/auth/oauth/__init__.py +26 -0
- ccproxy/auth/oauth/models.py +49 -0
- ccproxy/auth/oauth/routes.py +396 -0
- ccproxy/auth/oauth/storage.py +0 -0
- ccproxy/auth/storage/__init__.py +12 -0
- ccproxy/auth/storage/base.py +57 -0
- ccproxy/auth/storage/json_file.py +159 -0
- ccproxy/auth/storage/keyring.py +192 -0
- ccproxy/claude_sdk/__init__.py +20 -0
- ccproxy/claude_sdk/client.py +169 -0
- ccproxy/claude_sdk/converter.py +331 -0
- ccproxy/claude_sdk/options.py +120 -0
- ccproxy/cli/__init__.py +14 -0
- ccproxy/cli/commands/__init__.py +8 -0
- ccproxy/cli/commands/auth.py +553 -0
- ccproxy/cli/commands/config/__init__.py +14 -0
- ccproxy/cli/commands/config/commands.py +766 -0
- ccproxy/cli/commands/config/schema_commands.py +119 -0
- ccproxy/cli/commands/serve.py +630 -0
- ccproxy/cli/docker/__init__.py +34 -0
- ccproxy/cli/docker/adapter_factory.py +157 -0
- ccproxy/cli/docker/params.py +278 -0
- ccproxy/cli/helpers.py +144 -0
- ccproxy/cli/main.py +193 -0
- ccproxy/cli/options/__init__.py +14 -0
- ccproxy/cli/options/claude_options.py +216 -0
- ccproxy/cli/options/core_options.py +40 -0
- ccproxy/cli/options/security_options.py +48 -0
- ccproxy/cli/options/server_options.py +117 -0
- ccproxy/config/__init__.py +40 -0
- ccproxy/config/auth.py +154 -0
- ccproxy/config/claude.py +124 -0
- ccproxy/config/cors.py +79 -0
- ccproxy/config/discovery.py +87 -0
- ccproxy/config/docker_settings.py +265 -0
- ccproxy/config/loader.py +108 -0
- ccproxy/config/observability.py +158 -0
- ccproxy/config/pricing.py +88 -0
- ccproxy/config/reverse_proxy.py +31 -0
- ccproxy/config/scheduler.py +89 -0
- ccproxy/config/security.py +14 -0
- ccproxy/config/server.py +81 -0
- ccproxy/config/settings.py +534 -0
- ccproxy/config/validators.py +231 -0
- ccproxy/core/__init__.py +274 -0
- ccproxy/core/async_utils.py +675 -0
- ccproxy/core/constants.py +97 -0
- ccproxy/core/errors.py +256 -0
- ccproxy/core/http.py +328 -0
- ccproxy/core/http_transformers.py +428 -0
- ccproxy/core/interfaces.py +247 -0
- ccproxy/core/logging.py +189 -0
- ccproxy/core/middleware.py +114 -0
- ccproxy/core/proxy.py +143 -0
- ccproxy/core/system.py +38 -0
- ccproxy/core/transformers.py +259 -0
- ccproxy/core/types.py +129 -0
- ccproxy/core/validators.py +288 -0
- ccproxy/docker/__init__.py +67 -0
- ccproxy/docker/adapter.py +588 -0
- ccproxy/docker/docker_path.py +207 -0
- ccproxy/docker/middleware.py +103 -0
- ccproxy/docker/models.py +228 -0
- ccproxy/docker/protocol.py +192 -0
- ccproxy/docker/stream_process.py +264 -0
- ccproxy/docker/validators.py +173 -0
- ccproxy/models/__init__.py +123 -0
- ccproxy/models/errors.py +42 -0
- ccproxy/models/messages.py +243 -0
- ccproxy/models/requests.py +85 -0
- ccproxy/models/responses.py +227 -0
- ccproxy/models/types.py +102 -0
- ccproxy/observability/__init__.py +51 -0
- ccproxy/observability/access_logger.py +400 -0
- ccproxy/observability/context.py +447 -0
- ccproxy/observability/metrics.py +539 -0
- ccproxy/observability/pushgateway.py +366 -0
- ccproxy/observability/sse_events.py +303 -0
- ccproxy/observability/stats_printer.py +755 -0
- ccproxy/observability/storage/__init__.py +1 -0
- ccproxy/observability/storage/duckdb_simple.py +665 -0
- ccproxy/observability/storage/models.py +55 -0
- ccproxy/pricing/__init__.py +19 -0
- ccproxy/pricing/cache.py +212 -0
- ccproxy/pricing/loader.py +267 -0
- ccproxy/pricing/models.py +106 -0
- ccproxy/pricing/updater.py +309 -0
- ccproxy/scheduler/__init__.py +39 -0
- ccproxy/scheduler/core.py +335 -0
- ccproxy/scheduler/exceptions.py +34 -0
- ccproxy/scheduler/manager.py +186 -0
- ccproxy/scheduler/registry.py +150 -0
- ccproxy/scheduler/tasks.py +484 -0
- ccproxy/services/__init__.py +10 -0
- ccproxy/services/claude_sdk_service.py +614 -0
- ccproxy/services/credentials/__init__.py +55 -0
- ccproxy/services/credentials/config.py +105 -0
- ccproxy/services/credentials/manager.py +562 -0
- ccproxy/services/credentials/oauth_client.py +482 -0
- ccproxy/services/proxy_service.py +1536 -0
- ccproxy/static/.keep +0 -0
- ccproxy/testing/__init__.py +34 -0
- ccproxy/testing/config.py +148 -0
- ccproxy/testing/content_generation.py +197 -0
- ccproxy/testing/mock_responses.py +262 -0
- ccproxy/testing/response_handlers.py +161 -0
- ccproxy/testing/scenarios.py +241 -0
- ccproxy/utils/__init__.py +6 -0
- ccproxy/utils/cost_calculator.py +210 -0
- ccproxy/utils/streaming_metrics.py +199 -0
- ccproxy_api-0.1.0.dist-info/METADATA +253 -0
- ccproxy_api-0.1.0.dist-info/RECORD +148 -0
- ccproxy_api-0.1.0.dist-info/WHEEL +4 -0
- ccproxy_api-0.1.0.dist-info/entry_points.txt +2 -0
- ccproxy_api-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Docker path management with clean API."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Self
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, field_validator
|
|
7
|
+
from structlog import get_logger
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
logger = get_logger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DockerPath(BaseModel):
|
|
14
|
+
"""Represents a mapping between host and container paths.
|
|
15
|
+
|
|
16
|
+
Provides a clean API for Docker volume mounting and path resolution.
|
|
17
|
+
|
|
18
|
+
Example:
|
|
19
|
+
workspace = DockerPath(host_path="/some/host/local/path", container_path="/tmp/docker/workspace")
|
|
20
|
+
docker_vol = workspace.vol() # Returns volume mapping tuple
|
|
21
|
+
container_path = workspace.container() # Returns container path
|
|
22
|
+
host_path = workspace.host() # Returns host path
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
host_path: Path | None = None
|
|
26
|
+
container_path: str
|
|
27
|
+
env_definition_variable_name: str | None = None
|
|
28
|
+
|
|
29
|
+
@field_validator("host_path", mode="before")
|
|
30
|
+
@classmethod
|
|
31
|
+
def _resolve_host_path(cls, v: str | Path | None) -> Path | None:
|
|
32
|
+
"""Resolve host path to an absolute path."""
|
|
33
|
+
if v is None:
|
|
34
|
+
return None
|
|
35
|
+
return Path(v).resolve()
|
|
36
|
+
|
|
37
|
+
def vol(self) -> tuple[str, str]:
|
|
38
|
+
"""Get Docker volume mapping tuple.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
tuple[str, str]: (host_path, container_path) for Docker -v flag
|
|
42
|
+
"""
|
|
43
|
+
if self.host_path is None:
|
|
44
|
+
raise ValueError("host_path is not set, cannot create volume mapping")
|
|
45
|
+
return (str(self.host_path), self.container_path)
|
|
46
|
+
|
|
47
|
+
def host(self) -> Path:
|
|
48
|
+
"""Get host path as Path object.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Path: Resolved host path
|
|
52
|
+
"""
|
|
53
|
+
if self.host_path is None:
|
|
54
|
+
raise ValueError("host_path is not set")
|
|
55
|
+
return self.host_path
|
|
56
|
+
|
|
57
|
+
def container(self) -> str:
|
|
58
|
+
"""Get container path as string.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
str: Container path
|
|
62
|
+
"""
|
|
63
|
+
return self.container_path
|
|
64
|
+
|
|
65
|
+
def join(self, *subpaths: str) -> "DockerPath":
|
|
66
|
+
"""Create new DockerPath with subpaths joined to both host and container paths.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
*subpaths: Path components to join
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
DockerPath: New instance with joined paths
|
|
73
|
+
"""
|
|
74
|
+
host_joined = self.host_path
|
|
75
|
+
if host_joined:
|
|
76
|
+
for subpath in subpaths:
|
|
77
|
+
host_joined = host_joined / subpath
|
|
78
|
+
|
|
79
|
+
container_joined = self.container_path
|
|
80
|
+
for subpath in subpaths:
|
|
81
|
+
container_joined = f"{container_joined}/{subpath}".replace("//", "/")
|
|
82
|
+
|
|
83
|
+
return DockerPath(host_path=host_joined, container_path=container_joined)
|
|
84
|
+
|
|
85
|
+
def get_env_definition(self) -> str:
|
|
86
|
+
return f"{self.env_definition_variable_name}={self.container_path} # {self.host_path}"
|
|
87
|
+
|
|
88
|
+
def __str__(self) -> str:
|
|
89
|
+
"""String representation showing the mapping."""
|
|
90
|
+
if self.host_path:
|
|
91
|
+
return f"DockerPath({self.host_path} -> {self.container_path})"
|
|
92
|
+
return f"DockerPath(container_path={self.container_path})"
|
|
93
|
+
|
|
94
|
+
def __repr__(self) -> str:
|
|
95
|
+
"""Detailed representation."""
|
|
96
|
+
return f"DockerPath(host_path={self.host_path!r}, container_path={self.container_path!r})"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class DockerPathSet:
|
|
100
|
+
"""Collection of named Docker paths for organized path management.
|
|
101
|
+
|
|
102
|
+
Example:
|
|
103
|
+
paths = DockerPathSet("/tmp/build")
|
|
104
|
+
paths.add("workspace", "/workspace")
|
|
105
|
+
paths.add("config", "/workspace/config")
|
|
106
|
+
|
|
107
|
+
workspace_vol = paths.get("workspace").vol()
|
|
108
|
+
config_path = paths.get("config").container()
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(self, base_host_path: str | Path | None = None) -> None:
|
|
112
|
+
"""Initialize Docker path set.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
base_host_path: Base path on host for all paths in this set
|
|
116
|
+
"""
|
|
117
|
+
self.base_host_path = Path(base_host_path).resolve() if base_host_path else None
|
|
118
|
+
self.paths: dict[str, DockerPath] = {}
|
|
119
|
+
self.logger = get_logger(f"{__name__}.{self.__class__.__name__}")
|
|
120
|
+
|
|
121
|
+
def add(
|
|
122
|
+
self, name: str, container_path: str, host_subpath: str | None = None
|
|
123
|
+
) -> Self:
|
|
124
|
+
"""Add a named Docker path to the set.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
name: Logical name for the path
|
|
128
|
+
container_path: Path inside the Docker container
|
|
129
|
+
host_subpath: Optional subpath from base_host_path, defaults to name
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Self: For method chaining
|
|
133
|
+
"""
|
|
134
|
+
if self.base_host_path is None:
|
|
135
|
+
raise ValueError("base_host_path must be set to use add() method")
|
|
136
|
+
|
|
137
|
+
if host_subpath is None:
|
|
138
|
+
host_subpath = name
|
|
139
|
+
|
|
140
|
+
# Handle empty string to mean no subpath (use base path directly)
|
|
141
|
+
if host_subpath == "":
|
|
142
|
+
host_path = self.base_host_path
|
|
143
|
+
else:
|
|
144
|
+
host_path = self.base_host_path / host_subpath
|
|
145
|
+
|
|
146
|
+
self.paths[name] = DockerPath(
|
|
147
|
+
host_path=host_path, container_path=container_path
|
|
148
|
+
)
|
|
149
|
+
return self
|
|
150
|
+
|
|
151
|
+
def add_path(self, name: str, docker_path: DockerPath) -> Self:
|
|
152
|
+
"""Add a pre-created DockerPath to the set.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
name: Logical name for the path
|
|
156
|
+
docker_path: DockerPath instance to add
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Self: For method chaining
|
|
160
|
+
"""
|
|
161
|
+
self.paths[name] = docker_path
|
|
162
|
+
return self
|
|
163
|
+
|
|
164
|
+
def get(self, name: str) -> DockerPath:
|
|
165
|
+
"""Get Docker path by name.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
name: Logical name of the path
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
DockerPath: The Docker path instance
|
|
172
|
+
|
|
173
|
+
Raises:
|
|
174
|
+
KeyError: If path name is not found
|
|
175
|
+
"""
|
|
176
|
+
if name not in self.paths:
|
|
177
|
+
raise KeyError(
|
|
178
|
+
f"Docker path '{name}' not found. Available: {list(self.paths.keys())}"
|
|
179
|
+
)
|
|
180
|
+
return self.paths[name]
|
|
181
|
+
|
|
182
|
+
def has(self, name: str) -> bool:
|
|
183
|
+
"""Check if a path name exists in the set.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
name: Logical name to check
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
bool: True if path exists
|
|
190
|
+
"""
|
|
191
|
+
return name in self.paths
|
|
192
|
+
|
|
193
|
+
def volumes(self) -> list[tuple[str, str]]:
|
|
194
|
+
"""Get all volume mappings for Docker.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
list[tuple[str, str]]: List of (host_path, container_path) tuples
|
|
198
|
+
"""
|
|
199
|
+
return [path.vol() for path in self.paths.values()]
|
|
200
|
+
|
|
201
|
+
def names(self) -> list[str]:
|
|
202
|
+
"""Get all path names in the set.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
list[str]: List of logical path names
|
|
206
|
+
"""
|
|
207
|
+
return list(self.paths.keys())
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Docker output middleware for processing and logging container output."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from structlog import get_logger
|
|
6
|
+
|
|
7
|
+
from .stream_process import OutputMiddleware, create_chained_middleware
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
logger = get_logger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LoggerOutputMiddleware(OutputMiddleware[str]):
|
|
14
|
+
"""Simple middleware that prints output with optional prefixes.
|
|
15
|
+
|
|
16
|
+
This middleware prints each line to the console with configurable
|
|
17
|
+
prefixes for stdout and stderr streams.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, logger: Any, stdout_prefix: str = "", stderr_prefix: str = ""):
|
|
21
|
+
"""Initialize middleware with custom prefixes.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
stdout_prefix: Prefix for stdout lines (default: "")
|
|
25
|
+
stderr_prefix: Prefix for stderr lines (default: "")
|
|
26
|
+
"""
|
|
27
|
+
self.logger = logger
|
|
28
|
+
self.stderr_prefix = stderr_prefix
|
|
29
|
+
self.stdout_prefix = stdout_prefix
|
|
30
|
+
|
|
31
|
+
async def process(self, line: str, stream_type: str) -> str:
|
|
32
|
+
"""Process and print a line with the appropriate prefix.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
line: Output line to process
|
|
36
|
+
stream_type: Either "stdout" or "stderr"
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
The original line (unmodified)
|
|
40
|
+
"""
|
|
41
|
+
if stream_type == "stdout":
|
|
42
|
+
self.logger.info(
|
|
43
|
+
"docker_stdout", prefix=self.stdout_prefix, line=line, stream="stdout"
|
|
44
|
+
)
|
|
45
|
+
else:
|
|
46
|
+
self.logger.info(
|
|
47
|
+
"docker_stderr", prefix=self.stderr_prefix, line=line, stream="stderr"
|
|
48
|
+
)
|
|
49
|
+
return line
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def create_logger_middleware(
|
|
53
|
+
logger_instance: Any | None = None,
|
|
54
|
+
stdout_prefix: str = "",
|
|
55
|
+
stderr_prefix: str = "",
|
|
56
|
+
) -> LoggerOutputMiddleware:
|
|
57
|
+
"""Factory function to create a LoggerOutputMiddleware instance.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
logger_instance: Logger instance to use (defaults to module logger)
|
|
61
|
+
stdout_prefix: Prefix for stdout lines
|
|
62
|
+
stderr_prefix: Prefix for stderr lines
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Configured LoggerOutputMiddleware instance
|
|
66
|
+
"""
|
|
67
|
+
if logger_instance is None:
|
|
68
|
+
logger_instance = logger
|
|
69
|
+
return LoggerOutputMiddleware(logger_instance, stdout_prefix, stderr_prefix)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def create_chained_docker_middleware(
|
|
73
|
+
middleware_chain: list[OutputMiddleware[Any]],
|
|
74
|
+
include_logger: bool = True,
|
|
75
|
+
logger_instance: Any | None = None,
|
|
76
|
+
stdout_prefix: str = "",
|
|
77
|
+
stderr_prefix: str = "",
|
|
78
|
+
) -> OutputMiddleware[Any]:
|
|
79
|
+
"""Factory function to create chained middleware for Docker operations.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
middleware_chain: List of middleware components to chain together
|
|
83
|
+
include_logger: Whether to automatically add logger middleware at the end
|
|
84
|
+
logger_instance: Logger instance to use (defaults to module logger)
|
|
85
|
+
stdout_prefix: Prefix for stdout lines in logger middleware
|
|
86
|
+
stderr_prefix: Prefix for stderr lines in logger middleware
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Chained middleware instance
|
|
90
|
+
|
|
91
|
+
"""
|
|
92
|
+
final_chain = list(middleware_chain)
|
|
93
|
+
|
|
94
|
+
if include_logger:
|
|
95
|
+
logger_middleware = create_logger_middleware(
|
|
96
|
+
logger_instance, stdout_prefix, stderr_prefix
|
|
97
|
+
)
|
|
98
|
+
final_chain.append(logger_middleware)
|
|
99
|
+
|
|
100
|
+
if len(final_chain) == 1:
|
|
101
|
+
return final_chain[0]
|
|
102
|
+
|
|
103
|
+
return create_chained_middleware(final_chain)
|
ccproxy/docker/models.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Docker-specific models for cross-domain operations."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import ClassVar, Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field, field_validator
|
|
9
|
+
|
|
10
|
+
from .docker_path import DockerPath
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DockerUserContext(BaseModel):
|
|
14
|
+
"""Docker user context for volume permission handling.
|
|
15
|
+
|
|
16
|
+
Represents user information needed for Docker --user flag to
|
|
17
|
+
solve volume permission issues when mounting host directories.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
uid: int = Field(..., description="User ID for Docker --user flag")
|
|
21
|
+
gid: int = Field(..., description="Group ID for Docker --user flag")
|
|
22
|
+
username: str = Field(..., description="Username for reference")
|
|
23
|
+
enable_user_mapping: bool = Field(
|
|
24
|
+
default=True, description="Whether to enable --user flag in Docker commands"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Path settings using DockerPath
|
|
28
|
+
home_path: DockerPath | None = Field(
|
|
29
|
+
default=None, description="Home directory mapping between host and container"
|
|
30
|
+
)
|
|
31
|
+
workspace_path: DockerPath | None = Field(
|
|
32
|
+
default=None,
|
|
33
|
+
description="Workspace directory mapping between host and container",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Platform compatibility
|
|
37
|
+
_supported_platforms: ClassVar[set[str]] = {"Linux", "Darwin"}
|
|
38
|
+
|
|
39
|
+
@field_validator("uid", "gid")
|
|
40
|
+
@classmethod
|
|
41
|
+
def validate_positive_ids(cls, v: int) -> int:
|
|
42
|
+
"""Validate that UID/GID are positive integers."""
|
|
43
|
+
if v < 0:
|
|
44
|
+
raise ValueError("UID and GID must be non-negative")
|
|
45
|
+
return v
|
|
46
|
+
|
|
47
|
+
@field_validator("username")
|
|
48
|
+
@classmethod
|
|
49
|
+
def validate_username(cls, v: str) -> str:
|
|
50
|
+
"""Validate username is not empty."""
|
|
51
|
+
if not v or not v.strip():
|
|
52
|
+
raise ValueError("Username cannot be empty")
|
|
53
|
+
return v.strip()
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def detect_current_user(
|
|
57
|
+
cls,
|
|
58
|
+
home_path: DockerPath | None = None,
|
|
59
|
+
workspace_path: DockerPath | None = None,
|
|
60
|
+
) -> "DockerUserContext":
|
|
61
|
+
"""Detect current user context from system.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
home_path: Optional home directory DockerPath override
|
|
65
|
+
workspace_path: Optional workspace directory DockerPath override
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
DockerUserContext: Current user's context
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
RuntimeError: If user detection fails or platform unsupported
|
|
72
|
+
"""
|
|
73
|
+
current_platform = platform.system()
|
|
74
|
+
|
|
75
|
+
if current_platform not in cls._supported_platforms:
|
|
76
|
+
raise RuntimeError(
|
|
77
|
+
f"User detection not supported on {current_platform}. "
|
|
78
|
+
f"Supported platforms: {', '.join(cls._supported_platforms)}"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
uid = os.getuid()
|
|
83
|
+
gid = os.getgid()
|
|
84
|
+
username = os.getenv("USER") or os.getenv("USERNAME") or "unknown"
|
|
85
|
+
|
|
86
|
+
# Create default home path if not provided
|
|
87
|
+
if home_path is None:
|
|
88
|
+
host_home_env = os.getenv("HOME")
|
|
89
|
+
if host_home_env:
|
|
90
|
+
home_path = DockerPath(
|
|
91
|
+
host_path=Path(host_home_env), container_path="/data/home"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return cls(
|
|
95
|
+
uid=uid,
|
|
96
|
+
gid=gid,
|
|
97
|
+
username=username,
|
|
98
|
+
enable_user_mapping=True,
|
|
99
|
+
home_path=home_path,
|
|
100
|
+
workspace_path=workspace_path,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
except AttributeError as e:
|
|
104
|
+
raise RuntimeError(
|
|
105
|
+
f"Failed to detect user on {current_platform}: {e}"
|
|
106
|
+
) from e
|
|
107
|
+
except Exception as e:
|
|
108
|
+
raise RuntimeError(f"Unexpected error detecting user: {e}") from e
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def create_manual(
|
|
112
|
+
cls,
|
|
113
|
+
uid: int,
|
|
114
|
+
gid: int,
|
|
115
|
+
username: str,
|
|
116
|
+
home_path: DockerPath | None = None,
|
|
117
|
+
workspace_path: DockerPath | None = None,
|
|
118
|
+
enable_user_mapping: bool = True,
|
|
119
|
+
) -> "DockerUserContext":
|
|
120
|
+
"""Create manual user context with custom values.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
uid: User ID for Docker --user flag
|
|
124
|
+
gid: Group ID for Docker --user flag
|
|
125
|
+
username: Username for reference
|
|
126
|
+
home_path: Optional home directory DockerPath
|
|
127
|
+
workspace_path: Optional workspace directory DockerPath
|
|
128
|
+
enable_user_mapping: Whether to enable --user flag in Docker commands
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
DockerUserContext: Manual user context
|
|
132
|
+
|
|
133
|
+
Raises:
|
|
134
|
+
ValueError: If validation fails for any parameter
|
|
135
|
+
"""
|
|
136
|
+
return cls(
|
|
137
|
+
uid=uid,
|
|
138
|
+
gid=gid,
|
|
139
|
+
username=username,
|
|
140
|
+
enable_user_mapping=enable_user_mapping,
|
|
141
|
+
home_path=home_path,
|
|
142
|
+
workspace_path=workspace_path,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def get_docker_user_flag(self) -> str:
|
|
146
|
+
"""Get Docker --user flag value.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
str: Docker user flag in format "uid:gid"
|
|
150
|
+
"""
|
|
151
|
+
return f"{self.uid}:{self.gid}"
|
|
152
|
+
|
|
153
|
+
def is_supported_platform(self) -> bool:
|
|
154
|
+
"""Check if current platform supports user mapping.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
bool: True if platform supports user mapping
|
|
158
|
+
"""
|
|
159
|
+
return platform.system() in self._supported_platforms
|
|
160
|
+
|
|
161
|
+
def should_use_user_mapping(self) -> bool:
|
|
162
|
+
"""Check if user mapping should be used.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
bool: True if user mapping is enabled and platform is supported
|
|
166
|
+
"""
|
|
167
|
+
return self.enable_user_mapping and self.is_supported_platform()
|
|
168
|
+
|
|
169
|
+
def get_environment_variables(self) -> dict[str, str]:
|
|
170
|
+
"""Get environment variables for home and workspace directory configuration.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
dict[str, str]: Environment variables to set in container
|
|
174
|
+
"""
|
|
175
|
+
env = {}
|
|
176
|
+
if self.home_path:
|
|
177
|
+
env["HOME"] = self.home_path.container()
|
|
178
|
+
env["CLAUDE_HOME"] = self.home_path.container()
|
|
179
|
+
if self.workspace_path:
|
|
180
|
+
env["CLAUDE_WORKSPACE"] = self.workspace_path.container()
|
|
181
|
+
return env
|
|
182
|
+
|
|
183
|
+
def get_volumes(self) -> list[tuple[str, str]]:
|
|
184
|
+
"""Get Docker volume mappings for home and workspace directories.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
list[tuple[str, str]]: List of (host_path, container_path) tuples
|
|
188
|
+
"""
|
|
189
|
+
volumes = []
|
|
190
|
+
if self.home_path and self.home_path.host_path:
|
|
191
|
+
volumes.append(self.home_path.vol())
|
|
192
|
+
if self.workspace_path and self.workspace_path.host_path:
|
|
193
|
+
volumes.append(self.workspace_path.vol())
|
|
194
|
+
return volumes
|
|
195
|
+
|
|
196
|
+
def get_home_volumes(self) -> list[tuple[str, str]]:
|
|
197
|
+
"""Get Docker volume mappings for home directory only (for backwards compatibility).
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
list[tuple[str, str]]: List of (host_path, container_path) tuples
|
|
201
|
+
"""
|
|
202
|
+
volumes = []
|
|
203
|
+
if self.home_path and self.home_path.host_path:
|
|
204
|
+
volumes.append(self.home_path.vol())
|
|
205
|
+
return volumes
|
|
206
|
+
|
|
207
|
+
def describe_context(self) -> str:
|
|
208
|
+
"""Get human-readable description of user context.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
str: Description of user context for debugging
|
|
212
|
+
"""
|
|
213
|
+
parts = [
|
|
214
|
+
f"uid={self.uid}",
|
|
215
|
+
f"gid={self.gid}",
|
|
216
|
+
f"username={self.username}",
|
|
217
|
+
]
|
|
218
|
+
|
|
219
|
+
if self.home_path:
|
|
220
|
+
parts.append(f"home_path={self.home_path}")
|
|
221
|
+
|
|
222
|
+
if self.workspace_path:
|
|
223
|
+
parts.append(f"workspace_path={self.workspace_path}")
|
|
224
|
+
|
|
225
|
+
return f"DockerUserContext({', '.join(parts)})"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
__all__ = ["DockerUserContext"]
|