scc-cli 1.4.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.
Potentially problematic release.
This version of scc-cli might be problematic. Click here for more details.
- scc_cli/__init__.py +15 -0
- scc_cli/audit/__init__.py +37 -0
- scc_cli/audit/parser.py +191 -0
- scc_cli/audit/reader.py +180 -0
- scc_cli/auth.py +145 -0
- scc_cli/claude_adapter.py +485 -0
- scc_cli/cli.py +259 -0
- scc_cli/cli_admin.py +683 -0
- scc_cli/cli_audit.py +245 -0
- scc_cli/cli_common.py +166 -0
- scc_cli/cli_config.py +527 -0
- scc_cli/cli_exceptions.py +705 -0
- scc_cli/cli_helpers.py +244 -0
- scc_cli/cli_init.py +272 -0
- scc_cli/cli_launch.py +1400 -0
- scc_cli/cli_org.py +1433 -0
- scc_cli/cli_support.py +322 -0
- scc_cli/cli_team.py +858 -0
- scc_cli/cli_worktree.py +865 -0
- scc_cli/config.py +583 -0
- scc_cli/console.py +562 -0
- scc_cli/constants.py +79 -0
- scc_cli/contexts.py +377 -0
- scc_cli/deprecation.py +54 -0
- scc_cli/deps.py +189 -0
- scc_cli/docker/__init__.py +127 -0
- scc_cli/docker/core.py +466 -0
- scc_cli/docker/credentials.py +726 -0
- scc_cli/docker/launch.py +603 -0
- scc_cli/doctor/__init__.py +99 -0
- scc_cli/doctor/checks.py +1082 -0
- scc_cli/doctor/render.py +346 -0
- scc_cli/doctor/types.py +66 -0
- scc_cli/errors.py +288 -0
- scc_cli/evaluation/__init__.py +27 -0
- scc_cli/evaluation/apply_exceptions.py +207 -0
- scc_cli/evaluation/evaluate.py +97 -0
- scc_cli/evaluation/models.py +80 -0
- scc_cli/exit_codes.py +55 -0
- scc_cli/git.py +1405 -0
- scc_cli/json_command.py +166 -0
- scc_cli/json_output.py +96 -0
- scc_cli/kinds.py +62 -0
- scc_cli/marketplace/__init__.py +123 -0
- scc_cli/marketplace/compute.py +377 -0
- scc_cli/marketplace/constants.py +87 -0
- scc_cli/marketplace/managed.py +135 -0
- scc_cli/marketplace/materialize.py +723 -0
- scc_cli/marketplace/normalize.py +548 -0
- scc_cli/marketplace/render.py +238 -0
- scc_cli/marketplace/resolve.py +459 -0
- scc_cli/marketplace/schema.py +502 -0
- scc_cli/marketplace/sync.py +257 -0
- scc_cli/marketplace/team_cache.py +195 -0
- scc_cli/marketplace/team_fetch.py +688 -0
- scc_cli/marketplace/trust.py +244 -0
- scc_cli/models/__init__.py +41 -0
- scc_cli/models/exceptions.py +273 -0
- scc_cli/models/plugin_audit.py +434 -0
- scc_cli/org_templates.py +269 -0
- scc_cli/output_mode.py +167 -0
- scc_cli/panels.py +113 -0
- scc_cli/platform.py +350 -0
- scc_cli/profiles.py +1034 -0
- scc_cli/remote.py +443 -0
- scc_cli/schemas/__init__.py +1 -0
- scc_cli/schemas/org-v1.schema.json +456 -0
- scc_cli/schemas/team-config.v1.schema.json +163 -0
- scc_cli/sessions.py +425 -0
- scc_cli/setup.py +582 -0
- scc_cli/source_resolver.py +470 -0
- scc_cli/stats.py +378 -0
- scc_cli/stores/__init__.py +13 -0
- scc_cli/stores/exception_store.py +251 -0
- scc_cli/subprocess_utils.py +88 -0
- scc_cli/teams.py +339 -0
- scc_cli/templates/__init__.py +2 -0
- scc_cli/templates/org/__init__.py +0 -0
- scc_cli/templates/org/minimal.json +19 -0
- scc_cli/templates/org/reference.json +74 -0
- scc_cli/templates/org/strict.json +38 -0
- scc_cli/templates/org/teams.json +42 -0
- scc_cli/templates/statusline.sh +75 -0
- scc_cli/theme.py +348 -0
- scc_cli/ui/__init__.py +124 -0
- scc_cli/ui/branding.py +68 -0
- scc_cli/ui/chrome.py +395 -0
- scc_cli/ui/dashboard/__init__.py +62 -0
- scc_cli/ui/dashboard/_dashboard.py +669 -0
- scc_cli/ui/dashboard/loaders.py +369 -0
- scc_cli/ui/dashboard/models.py +184 -0
- scc_cli/ui/dashboard/orchestrator.py +337 -0
- scc_cli/ui/formatters.py +443 -0
- scc_cli/ui/gate.py +350 -0
- scc_cli/ui/help.py +157 -0
- scc_cli/ui/keys.py +521 -0
- scc_cli/ui/list_screen.py +431 -0
- scc_cli/ui/picker.py +700 -0
- scc_cli/ui/prompts.py +200 -0
- scc_cli/ui/wizard.py +490 -0
- scc_cli/update.py +680 -0
- scc_cli/utils/__init__.py +39 -0
- scc_cli/utils/fixit.py +264 -0
- scc_cli/utils/fuzzy.py +124 -0
- scc_cli/utils/locks.py +101 -0
- scc_cli/utils/ttl.py +376 -0
- scc_cli/validate.py +455 -0
- scc_cli-1.4.0.dist-info/METADATA +369 -0
- scc_cli-1.4.0.dist-info/RECORD +112 -0
- scc_cli-1.4.0.dist-info/WHEEL +4 -0
- scc_cli-1.4.0.dist-info/entry_points.txt +2 -0
- scc_cli-1.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Docker sandbox operations.
|
|
3
|
+
|
|
4
|
+
This package provides Docker sandbox lifecycle management with
|
|
5
|
+
credential persistence across project switches.
|
|
6
|
+
|
|
7
|
+
===============================================================================
|
|
8
|
+
CREDENTIAL PERSISTENCE ARCHITECTURE (DO NOT MODIFY)
|
|
9
|
+
===============================================================================
|
|
10
|
+
|
|
11
|
+
PROBLEM: OAuth credentials lost when switching projects. Claude reads config
|
|
12
|
+
before symlinks are created (race condition).
|
|
13
|
+
|
|
14
|
+
SOLUTION (Synchronous Detached Pattern):
|
|
15
|
+
1. docker sandbox run -d -w /path claude → Creates container, returns ID
|
|
16
|
+
2. docker exec <id> <symlink_script> → Creates symlinks while idle
|
|
17
|
+
3. docker exec -it <id> claude → Runs Claude after symlinks exist
|
|
18
|
+
|
|
19
|
+
CRITICAL - DO NOT CHANGE:
|
|
20
|
+
- Agent name `claude` is REQUIRED even in detached mode (-d)!
|
|
21
|
+
Wrong: docker sandbox run -d -w /path
|
|
22
|
+
Right: docker sandbox run -d -w /path claude
|
|
23
|
+
- Session flags (-c, --resume) passed via docker exec, NOT container creation
|
|
24
|
+
|
|
25
|
+
See run_sandbox() and build_command() for implementation.
|
|
26
|
+
===============================================================================
|
|
27
|
+
|
|
28
|
+
Module Structure:
|
|
29
|
+
- core.py: Docker primitives (checks, commands, container lifecycle)
|
|
30
|
+
- credentials.py: Credential persistence subsystem
|
|
31
|
+
- launch.py: High-level launch orchestration and settings
|
|
32
|
+
|
|
33
|
+
All public symbols are re-exported here for backward compatibility.
|
|
34
|
+
Import from scc_cli.docker, not from submodules.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
# Re-export subprocess utilities for test patching compatibility
|
|
38
|
+
from ..subprocess_utils import run_command, run_command_bool
|
|
39
|
+
|
|
40
|
+
# Re-export from core.py
|
|
41
|
+
from .core import (
|
|
42
|
+
LABEL_PREFIX,
|
|
43
|
+
MIN_DOCKER_VERSION,
|
|
44
|
+
ContainerInfo,
|
|
45
|
+
_check_docker_installed,
|
|
46
|
+
_list_all_sandbox_containers,
|
|
47
|
+
_parse_version,
|
|
48
|
+
build_command,
|
|
49
|
+
build_labels,
|
|
50
|
+
build_start_command,
|
|
51
|
+
check_docker_available,
|
|
52
|
+
check_docker_sandbox,
|
|
53
|
+
container_exists,
|
|
54
|
+
generate_container_name,
|
|
55
|
+
get_container_status,
|
|
56
|
+
get_docker_version,
|
|
57
|
+
list_running_sandboxes,
|
|
58
|
+
list_scc_containers,
|
|
59
|
+
remove_container,
|
|
60
|
+
resume_container,
|
|
61
|
+
run_detached,
|
|
62
|
+
start_container,
|
|
63
|
+
stop_container,
|
|
64
|
+
validate_container_filename,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Re-export from credentials.py
|
|
68
|
+
from .credentials import (
|
|
69
|
+
prepare_sandbox_volume_for_credentials,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Re-export from launch.py
|
|
73
|
+
from .launch import (
|
|
74
|
+
get_or_create_container,
|
|
75
|
+
get_sandbox_settings,
|
|
76
|
+
inject_file_to_sandbox_volume,
|
|
77
|
+
inject_settings,
|
|
78
|
+
inject_team_settings,
|
|
79
|
+
run,
|
|
80
|
+
run_sandbox,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
__all__ = [
|
|
84
|
+
# Constants
|
|
85
|
+
"MIN_DOCKER_VERSION",
|
|
86
|
+
"LABEL_PREFIX",
|
|
87
|
+
# Data classes
|
|
88
|
+
"ContainerInfo",
|
|
89
|
+
# Docker checks
|
|
90
|
+
"check_docker_available",
|
|
91
|
+
"check_docker_sandbox",
|
|
92
|
+
"get_docker_version",
|
|
93
|
+
# Container lifecycle
|
|
94
|
+
"container_exists",
|
|
95
|
+
"get_container_status",
|
|
96
|
+
"start_container",
|
|
97
|
+
"stop_container",
|
|
98
|
+
"remove_container",
|
|
99
|
+
"resume_container",
|
|
100
|
+
"run_detached",
|
|
101
|
+
# Command building
|
|
102
|
+
"build_command",
|
|
103
|
+
"build_start_command",
|
|
104
|
+
"build_labels",
|
|
105
|
+
"generate_container_name",
|
|
106
|
+
"validate_container_filename",
|
|
107
|
+
# Container queries
|
|
108
|
+
"list_scc_containers",
|
|
109
|
+
"list_running_sandboxes",
|
|
110
|
+
# Credential management
|
|
111
|
+
"prepare_sandbox_volume_for_credentials",
|
|
112
|
+
# Settings injection
|
|
113
|
+
"inject_file_to_sandbox_volume",
|
|
114
|
+
"get_sandbox_settings",
|
|
115
|
+
"inject_settings",
|
|
116
|
+
"inject_team_settings",
|
|
117
|
+
# High-level launch functions
|
|
118
|
+
"run",
|
|
119
|
+
"run_sandbox",
|
|
120
|
+
"get_or_create_container",
|
|
121
|
+
# Re-exported for test patching compatibility
|
|
122
|
+
"run_command",
|
|
123
|
+
"run_command_bool",
|
|
124
|
+
"_check_docker_installed",
|
|
125
|
+
"_list_all_sandbox_containers",
|
|
126
|
+
"_parse_version",
|
|
127
|
+
]
|
scc_cli/docker/core.py
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Provide Docker core operations: checks, commands, container lifecycle, and queries.
|
|
3
|
+
|
|
4
|
+
Contain stateless Docker primitives that don't manage persistent state.
|
|
5
|
+
For credential persistence, see credentials.py.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import datetime
|
|
9
|
+
import hashlib
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from ..constants import SANDBOX_IMAGE
|
|
18
|
+
from ..errors import (
|
|
19
|
+
ContainerNotFoundError,
|
|
20
|
+
DockerNotFoundError,
|
|
21
|
+
DockerVersionError,
|
|
22
|
+
SandboxNotAvailableError,
|
|
23
|
+
)
|
|
24
|
+
from ..subprocess_utils import run_command, run_command_bool
|
|
25
|
+
|
|
26
|
+
# Minimum Docker Desktop version required for sandbox feature
|
|
27
|
+
MIN_DOCKER_VERSION = "4.50.0"
|
|
28
|
+
|
|
29
|
+
# Label prefix for SCC containers
|
|
30
|
+
LABEL_PREFIX = "scc"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ContainerInfo:
|
|
35
|
+
"""Information about an SCC container."""
|
|
36
|
+
|
|
37
|
+
id: str
|
|
38
|
+
name: str
|
|
39
|
+
status: str
|
|
40
|
+
profile: str | None = None
|
|
41
|
+
workspace: str | None = None
|
|
42
|
+
branch: str | None = None
|
|
43
|
+
created: str | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _check_docker_installed() -> bool:
|
|
47
|
+
"""Check whether Docker is installed and in PATH."""
|
|
48
|
+
return shutil.which("docker") is not None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parse_version(version_string: str) -> tuple[int, int, int]:
|
|
52
|
+
"""Parse version string into comparable tuple."""
|
|
53
|
+
# Extract version number from strings like "Docker version 27.5.1, build..."
|
|
54
|
+
match = re.search(r"(\d+)\.(\d+)\.(\d+)", version_string)
|
|
55
|
+
if match:
|
|
56
|
+
major, minor, patch = (int(x) for x in match.groups())
|
|
57
|
+
return (major, minor, patch)
|
|
58
|
+
return (0, 0, 0)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def check_docker_available() -> None:
|
|
62
|
+
"""
|
|
63
|
+
Check if Docker is available and meets requirements.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
DockerNotFoundError: Docker is not installed
|
|
67
|
+
DockerVersionError: Docker version is too old
|
|
68
|
+
SandboxNotAvailableError: Sandbox feature not available
|
|
69
|
+
"""
|
|
70
|
+
# Check Docker is installed
|
|
71
|
+
if not _check_docker_installed():
|
|
72
|
+
raise DockerNotFoundError()
|
|
73
|
+
|
|
74
|
+
# Check Docker version
|
|
75
|
+
version = get_docker_version()
|
|
76
|
+
if version:
|
|
77
|
+
current = _parse_version(version)
|
|
78
|
+
required = _parse_version(MIN_DOCKER_VERSION)
|
|
79
|
+
if current < required:
|
|
80
|
+
raise DockerVersionError(current_version=version)
|
|
81
|
+
|
|
82
|
+
# Check sandbox command exists
|
|
83
|
+
if not check_docker_sandbox():
|
|
84
|
+
raise SandboxNotAvailableError()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def check_docker_sandbox() -> bool:
|
|
88
|
+
"""Check whether Docker sandbox feature is available (Docker Desktop 4.50+)."""
|
|
89
|
+
if not _check_docker_installed():
|
|
90
|
+
return False
|
|
91
|
+
return run_command_bool(["docker", "sandbox", "--help"], timeout=10)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def get_docker_version() -> str | None:
|
|
95
|
+
"""Get Docker version string."""
|
|
96
|
+
return run_command(["docker", "--version"], timeout=5)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def generate_container_name(workspace: Path, branch: str | None = None) -> str:
|
|
100
|
+
"""
|
|
101
|
+
Generate deterministic container name from workspace and branch.
|
|
102
|
+
|
|
103
|
+
Format: scc-<workspace_name>-<hash>
|
|
104
|
+
Example: scc-eneo-platform-a1b2c3
|
|
105
|
+
"""
|
|
106
|
+
# Sanitize workspace name (take last component, lowercase, alphanumeric only)
|
|
107
|
+
workspace_name = workspace.name.lower()
|
|
108
|
+
workspace_name = re.sub(r"[^a-z0-9]", "-", workspace_name)
|
|
109
|
+
workspace_name = re.sub(r"-+", "-", workspace_name).strip("-")
|
|
110
|
+
|
|
111
|
+
# Create hash from full workspace path + branch
|
|
112
|
+
hash_input = str(workspace.resolve())
|
|
113
|
+
if branch:
|
|
114
|
+
hash_input += f":{branch}"
|
|
115
|
+
hash_suffix = hashlib.sha256(hash_input.encode()).hexdigest()[:8]
|
|
116
|
+
|
|
117
|
+
return f"scc-{workspace_name}-{hash_suffix}"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def container_exists(container_name: str) -> bool:
|
|
121
|
+
"""Check whether a container with the given name exists (running or stopped)."""
|
|
122
|
+
output = run_command(
|
|
123
|
+
[
|
|
124
|
+
"docker",
|
|
125
|
+
"ps",
|
|
126
|
+
"-a",
|
|
127
|
+
"--filter",
|
|
128
|
+
f"name=^{container_name}$",
|
|
129
|
+
"--format",
|
|
130
|
+
"{{.Names}}",
|
|
131
|
+
],
|
|
132
|
+
timeout=10,
|
|
133
|
+
)
|
|
134
|
+
return output is not None and container_name in output
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def get_container_status(container_name: str) -> str | None:
|
|
138
|
+
"""Return the status of a container (running, exited, etc.)."""
|
|
139
|
+
output = run_command(
|
|
140
|
+
[
|
|
141
|
+
"docker",
|
|
142
|
+
"ps",
|
|
143
|
+
"-a",
|
|
144
|
+
"--filter",
|
|
145
|
+
f"name=^{container_name}$",
|
|
146
|
+
"--format",
|
|
147
|
+
"{{.Status}}",
|
|
148
|
+
],
|
|
149
|
+
timeout=10,
|
|
150
|
+
)
|
|
151
|
+
return output if output else None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def build_labels(
|
|
155
|
+
profile: str | None = None,
|
|
156
|
+
workspace: Path | None = None,
|
|
157
|
+
branch: str | None = None,
|
|
158
|
+
) -> dict[str, str]:
|
|
159
|
+
"""Build Docker labels for container metadata."""
|
|
160
|
+
labels = {
|
|
161
|
+
f"{LABEL_PREFIX}.managed": "true",
|
|
162
|
+
f"{LABEL_PREFIX}.created": datetime.datetime.now().isoformat(),
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if profile:
|
|
166
|
+
labels[f"{LABEL_PREFIX}.profile"] = profile
|
|
167
|
+
if workspace:
|
|
168
|
+
labels[f"{LABEL_PREFIX}.workspace"] = str(workspace)
|
|
169
|
+
if branch:
|
|
170
|
+
labels[f"{LABEL_PREFIX}.branch"] = branch
|
|
171
|
+
|
|
172
|
+
return labels
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def build_command(
|
|
176
|
+
workspace: Path | None = None,
|
|
177
|
+
continue_session: bool = False,
|
|
178
|
+
resume: bool = False,
|
|
179
|
+
detached: bool = False,
|
|
180
|
+
policy_host_path: Path | None = None,
|
|
181
|
+
) -> list[str]:
|
|
182
|
+
"""
|
|
183
|
+
Build the docker sandbox run command.
|
|
184
|
+
|
|
185
|
+
Structure: docker sandbox run [options] claude [claude-options]
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
workspace: Path to mount as workspace (-w flag)
|
|
189
|
+
continue_session: Pass -c flag to Claude (ignored in detached mode)
|
|
190
|
+
resume: Pass --resume flag to Claude (ignored in detached mode)
|
|
191
|
+
detached: Create container without running agent (-d flag)
|
|
192
|
+
policy_host_path: Host path to safety net policy file to bind-mount read-only.
|
|
193
|
+
If provided, mounts at /mnt/claude-data/effective_policy.json:ro
|
|
194
|
+
and sets SCC_POLICY_PATH env var for the plugin.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
Command as list of strings
|
|
198
|
+
|
|
199
|
+
CRITICAL (DO NOT CHANGE):
|
|
200
|
+
- Agent `claude` is ALWAYS included, even in detached mode
|
|
201
|
+
- Session flags passed via docker exec in detached mode (see run_sandbox)
|
|
202
|
+
"""
|
|
203
|
+
from ..constants import SAFETY_NET_POLICY_FILENAME, SANDBOX_DATA_MOUNT
|
|
204
|
+
|
|
205
|
+
cmd = ["docker", "sandbox", "run"]
|
|
206
|
+
|
|
207
|
+
# Detached mode: create container without running Claude interactively
|
|
208
|
+
# This allows us to create symlinks BEFORE Claude starts
|
|
209
|
+
if detached:
|
|
210
|
+
cmd.append("-d")
|
|
211
|
+
|
|
212
|
+
# Add read-only bind mount for safety net policy (kernel-enforced security)
|
|
213
|
+
# This MUST be added before the agent name in the command
|
|
214
|
+
#
|
|
215
|
+
# Design note: We mount the FILE directly (not a directory) because:
|
|
216
|
+
# - Containers are ephemeral (recreated each `scc start`)
|
|
217
|
+
# - Policy is written before container creation, so new containers get current policy
|
|
218
|
+
# - If we ever support container reuse or hot-reload, switch to directory mount
|
|
219
|
+
# (file mounts pin to inode; atomic rename would be invisible to running container)
|
|
220
|
+
if policy_host_path is not None:
|
|
221
|
+
container_policy_path = f"{SANDBOX_DATA_MOUNT}/{SAFETY_NET_POLICY_FILENAME}"
|
|
222
|
+
# -v host_path:container_path:ro ← Kernel-enforced read-only
|
|
223
|
+
# Even sudo inside container cannot bypass `:ro` - requires CAP_SYS_ADMIN
|
|
224
|
+
# Use os.fspath() to reliably convert Path to string
|
|
225
|
+
cmd.extend(["-v", f"{os.fspath(policy_host_path)}:{container_policy_path}:ro"])
|
|
226
|
+
# Set SCC_POLICY_PATH env var so plugin knows where to read policy
|
|
227
|
+
cmd.extend(["-e", f"SCC_POLICY_PATH={container_policy_path}"])
|
|
228
|
+
|
|
229
|
+
# Add workspace mount
|
|
230
|
+
if workspace:
|
|
231
|
+
cmd.extend(["-w", str(workspace)])
|
|
232
|
+
|
|
233
|
+
# Agent name is ALWAYS required (docker sandbox run requires <agent>)
|
|
234
|
+
cmd.append("claude")
|
|
235
|
+
|
|
236
|
+
# In interactive mode (not detached), add Claude-specific arguments
|
|
237
|
+
# In detached mode, skip these - we'll pass them via docker exec later
|
|
238
|
+
if not detached:
|
|
239
|
+
if continue_session:
|
|
240
|
+
cmd.append("-c")
|
|
241
|
+
elif resume:
|
|
242
|
+
cmd.append("--resume")
|
|
243
|
+
|
|
244
|
+
return cmd
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def build_start_command(container_name: str) -> list[str]:
|
|
248
|
+
"""Build command to resume an existing container and return it."""
|
|
249
|
+
return ["docker", "start", "-ai", container_name]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def run_detached(cmd: list[str]) -> subprocess.Popen[bytes]:
|
|
253
|
+
"""Run Docker command in background and return the process handle."""
|
|
254
|
+
return subprocess.Popen(
|
|
255
|
+
cmd,
|
|
256
|
+
stdout=subprocess.DEVNULL,
|
|
257
|
+
stderr=subprocess.DEVNULL,
|
|
258
|
+
start_new_session=True,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def start_container(container_name: str) -> int:
|
|
263
|
+
"""
|
|
264
|
+
Start (resume) an existing container interactively.
|
|
265
|
+
|
|
266
|
+
Raises:
|
|
267
|
+
ContainerNotFoundError: If container doesn't exist
|
|
268
|
+
SandboxLaunchError: If start fails
|
|
269
|
+
"""
|
|
270
|
+
# Import here to avoid circular dependency
|
|
271
|
+
from .launch import run
|
|
272
|
+
|
|
273
|
+
if not container_exists(container_name):
|
|
274
|
+
raise ContainerNotFoundError(container_name=container_name)
|
|
275
|
+
|
|
276
|
+
cmd = build_start_command(container_name)
|
|
277
|
+
return run(cmd)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def stop_container(container_id: str) -> bool:
|
|
281
|
+
"""Stop a running container and return success status."""
|
|
282
|
+
return run_command_bool(["docker", "stop", container_id], timeout=30)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def resume_container(container_id: str) -> bool:
|
|
286
|
+
"""Start a stopped container in background and return success status.
|
|
287
|
+
|
|
288
|
+
Unlike start_container() which attaches interactively, this just starts
|
|
289
|
+
the container and returns immediately. Suitable for batch operations.
|
|
290
|
+
"""
|
|
291
|
+
return run_command_bool(["docker", "start", container_id], timeout=30)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def remove_container(container_name: str, force: bool = False) -> bool:
|
|
295
|
+
"""Remove a container and return success status."""
|
|
296
|
+
cmd = ["docker", "rm"]
|
|
297
|
+
if force:
|
|
298
|
+
cmd.append("-f")
|
|
299
|
+
cmd.append(container_name)
|
|
300
|
+
return run_command_bool(cmd, timeout=30)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _list_all_sandbox_containers() -> list[ContainerInfo]:
|
|
304
|
+
"""
|
|
305
|
+
List ALL Claude Code sandbox containers (running AND stopped).
|
|
306
|
+
|
|
307
|
+
This is critical for credential recovery - when user does /exit,
|
|
308
|
+
the container STOPS but still contains the OAuth credentials.
|
|
309
|
+
|
|
310
|
+
Returns list of ContainerInfo objects sorted by most recent first.
|
|
311
|
+
"""
|
|
312
|
+
try:
|
|
313
|
+
# Get ALL containers (not just running) filtered by sandbox image
|
|
314
|
+
result = subprocess.run(
|
|
315
|
+
[
|
|
316
|
+
"docker",
|
|
317
|
+
"ps",
|
|
318
|
+
"-a",
|
|
319
|
+
"--filter",
|
|
320
|
+
f"ancestor={SANDBOX_IMAGE}",
|
|
321
|
+
"--format",
|
|
322
|
+
"{{.ID}}\t{{.Names}}\t{{.Status}}",
|
|
323
|
+
],
|
|
324
|
+
capture_output=True,
|
|
325
|
+
text=True,
|
|
326
|
+
timeout=10,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
if result.returncode != 0:
|
|
330
|
+
return []
|
|
331
|
+
|
|
332
|
+
containers = []
|
|
333
|
+
for line in result.stdout.strip().split("\n"):
|
|
334
|
+
if line:
|
|
335
|
+
parts = line.split("\t")
|
|
336
|
+
if len(parts) >= 3:
|
|
337
|
+
containers.append(
|
|
338
|
+
ContainerInfo(
|
|
339
|
+
id=parts[0],
|
|
340
|
+
name=parts[1],
|
|
341
|
+
status=parts[2],
|
|
342
|
+
)
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
return containers
|
|
346
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
347
|
+
return []
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def list_scc_containers() -> list[ContainerInfo]:
|
|
351
|
+
"""Return all SCC-managed containers (running and stopped)."""
|
|
352
|
+
try:
|
|
353
|
+
result = subprocess.run(
|
|
354
|
+
[
|
|
355
|
+
"docker",
|
|
356
|
+
"ps",
|
|
357
|
+
"-a",
|
|
358
|
+
"--filter",
|
|
359
|
+
f"label={LABEL_PREFIX}.managed=true",
|
|
360
|
+
"--format",
|
|
361
|
+
'{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Label "scc.profile"}}\t{{.Label "scc.workspace"}}\t{{.Label "scc.branch"}}',
|
|
362
|
+
],
|
|
363
|
+
capture_output=True,
|
|
364
|
+
text=True,
|
|
365
|
+
timeout=10,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
if result.returncode != 0:
|
|
369
|
+
return []
|
|
370
|
+
|
|
371
|
+
containers = []
|
|
372
|
+
for line in result.stdout.strip().split("\n"):
|
|
373
|
+
if line:
|
|
374
|
+
parts = line.split("\t")
|
|
375
|
+
if len(parts) >= 3:
|
|
376
|
+
containers.append(
|
|
377
|
+
ContainerInfo(
|
|
378
|
+
id=parts[0],
|
|
379
|
+
name=parts[1],
|
|
380
|
+
status=parts[2],
|
|
381
|
+
profile=parts[3] if len(parts) > 3 else None,
|
|
382
|
+
workspace=parts[4] if len(parts) > 4 else None,
|
|
383
|
+
branch=parts[5] if len(parts) > 5 else None,
|
|
384
|
+
)
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
return containers
|
|
388
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
389
|
+
return []
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def list_running_sandboxes() -> list[ContainerInfo]:
|
|
393
|
+
"""
|
|
394
|
+
Return running Claude Code sandboxes (created by Docker Desktop).
|
|
395
|
+
|
|
396
|
+
Docker sandbox containers are identified by the sandbox image
|
|
397
|
+
(docker/sandbox-templates:claude-code).
|
|
398
|
+
"""
|
|
399
|
+
try:
|
|
400
|
+
# Filter by the Docker sandbox image
|
|
401
|
+
result = subprocess.run(
|
|
402
|
+
[
|
|
403
|
+
"docker",
|
|
404
|
+
"ps",
|
|
405
|
+
"--filter",
|
|
406
|
+
f"ancestor={SANDBOX_IMAGE}",
|
|
407
|
+
"--format",
|
|
408
|
+
"{{.ID}}\t{{.Names}}\t{{.Status}}",
|
|
409
|
+
],
|
|
410
|
+
capture_output=True,
|
|
411
|
+
text=True,
|
|
412
|
+
timeout=10,
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
if result.returncode != 0:
|
|
416
|
+
return []
|
|
417
|
+
|
|
418
|
+
sandboxes = []
|
|
419
|
+
for line in result.stdout.strip().split("\n"):
|
|
420
|
+
if line:
|
|
421
|
+
parts = line.split("\t")
|
|
422
|
+
if len(parts) >= 3:
|
|
423
|
+
sandboxes.append(
|
|
424
|
+
ContainerInfo(
|
|
425
|
+
id=parts[0],
|
|
426
|
+
name=parts[1],
|
|
427
|
+
status=parts[2],
|
|
428
|
+
)
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
return sandboxes
|
|
432
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
433
|
+
return []
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def validate_container_filename(filename: str) -> str:
|
|
437
|
+
"""Validate filename for injection into container volume.
|
|
438
|
+
|
|
439
|
+
SECURITY: Defense-in-depth against path traversal attacks.
|
|
440
|
+
Although files go to a Docker volume (low risk), we validate anyway.
|
|
441
|
+
|
|
442
|
+
Args:
|
|
443
|
+
filename: Filename to validate
|
|
444
|
+
|
|
445
|
+
Returns:
|
|
446
|
+
Validated filename
|
|
447
|
+
|
|
448
|
+
Raises:
|
|
449
|
+
ValueError: If filename contains path traversal or unsafe characters
|
|
450
|
+
"""
|
|
451
|
+
if not filename:
|
|
452
|
+
raise ValueError("Filename cannot be empty")
|
|
453
|
+
|
|
454
|
+
# Reject path separators (prevent ../../../etc/passwd attacks)
|
|
455
|
+
if "/" in filename or "\\" in filename:
|
|
456
|
+
raise ValueError(f"Invalid filename: path separators not allowed: {filename}")
|
|
457
|
+
|
|
458
|
+
# Reject hidden files starting with dot (e.g., .bashrc, .profile)
|
|
459
|
+
if filename.startswith("."):
|
|
460
|
+
raise ValueError(f"Invalid filename: hidden files not allowed: {filename}")
|
|
461
|
+
|
|
462
|
+
# Reject null bytes (can truncate strings in some contexts)
|
|
463
|
+
if "\x00" in filename:
|
|
464
|
+
raise ValueError("Invalid filename: null bytes not allowed")
|
|
465
|
+
|
|
466
|
+
return filename
|