scc-cli 1.5.3__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 +311 -0
- scc_cli/cli_common.py +190 -0
- scc_cli/cli_helpers.py +244 -0
- scc_cli/commands/__init__.py +20 -0
- scc_cli/commands/admin.py +708 -0
- scc_cli/commands/audit.py +246 -0
- scc_cli/commands/config.py +528 -0
- scc_cli/commands/exceptions.py +696 -0
- scc_cli/commands/init.py +272 -0
- scc_cli/commands/launch/__init__.py +73 -0
- scc_cli/commands/launch/app.py +1247 -0
- scc_cli/commands/launch/render.py +309 -0
- scc_cli/commands/launch/sandbox.py +135 -0
- scc_cli/commands/launch/workspace.py +339 -0
- scc_cli/commands/org/__init__.py +49 -0
- scc_cli/commands/org/_builders.py +264 -0
- scc_cli/commands/org/app.py +41 -0
- scc_cli/commands/org/import_cmd.py +267 -0
- scc_cli/commands/org/init_cmd.py +269 -0
- scc_cli/commands/org/schema_cmd.py +76 -0
- scc_cli/commands/org/status_cmd.py +157 -0
- scc_cli/commands/org/update_cmd.py +330 -0
- scc_cli/commands/org/validate_cmd.py +138 -0
- scc_cli/commands/support.py +323 -0
- scc_cli/commands/team.py +910 -0
- scc_cli/commands/worktree/__init__.py +72 -0
- scc_cli/commands/worktree/_helpers.py +57 -0
- scc_cli/commands/worktree/app.py +170 -0
- scc_cli/commands/worktree/container_commands.py +385 -0
- scc_cli/commands/worktree/context_commands.py +61 -0
- scc_cli/commands/worktree/session_commands.py +128 -0
- scc_cli/commands/worktree/worktree_commands.py +734 -0
- scc_cli/config.py +647 -0
- scc_cli/confirm.py +20 -0
- scc_cli/console.py +562 -0
- scc_cli/contexts.py +394 -0
- scc_cli/core/__init__.py +68 -0
- scc_cli/core/constants.py +101 -0
- scc_cli/core/errors.py +297 -0
- scc_cli/core/exit_codes.py +91 -0
- scc_cli/core/workspace.py +57 -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 +467 -0
- scc_cli/docker/credentials.py +726 -0
- scc_cli/docker/launch.py +595 -0
- scc_cli/doctor/__init__.py +105 -0
- scc_cli/doctor/checks/__init__.py +166 -0
- scc_cli/doctor/checks/cache.py +314 -0
- scc_cli/doctor/checks/config.py +107 -0
- scc_cli/doctor/checks/environment.py +182 -0
- scc_cli/doctor/checks/json_helpers.py +157 -0
- scc_cli/doctor/checks/organization.py +264 -0
- scc_cli/doctor/checks/worktree.py +278 -0
- scc_cli/doctor/render.py +365 -0
- scc_cli/doctor/types.py +66 -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/git.py +84 -0
- scc_cli/json_command.py +166 -0
- scc_cli/json_output.py +159 -0
- scc_cli/kinds.py +65 -0
- scc_cli/marketplace/__init__.py +123 -0
- scc_cli/marketplace/adapter.py +74 -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 +846 -0
- scc_cli/marketplace/normalize.py +548 -0
- scc_cli/marketplace/render.py +281 -0
- scc_cli/marketplace/resolve.py +459 -0
- scc_cli/marketplace/schema.py +506 -0
- scc_cli/marketplace/sync.py +279 -0
- scc_cli/marketplace/team_cache.py +195 -0
- scc_cli/marketplace/team_fetch.py +689 -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 +960 -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/services/__init__.py +1 -0
- scc_cli/services/git/__init__.py +79 -0
- scc_cli/services/git/branch.py +151 -0
- scc_cli/services/git/core.py +216 -0
- scc_cli/services/git/hooks.py +108 -0
- scc_cli/services/git/worktree.py +444 -0
- scc_cli/services/workspace/__init__.py +36 -0
- scc_cli/services/workspace/resolver.py +223 -0
- scc_cli/services/workspace/suspicious.py +200 -0
- scc_cli/sessions.py +425 -0
- scc_cli/setup.py +589 -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 +383 -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 +154 -0
- scc_cli/ui/branding.py +68 -0
- scc_cli/ui/chrome.py +401 -0
- scc_cli/ui/dashboard/__init__.py +62 -0
- scc_cli/ui/dashboard/_dashboard.py +794 -0
- scc_cli/ui/dashboard/loaders.py +452 -0
- scc_cli/ui/dashboard/models.py +185 -0
- scc_cli/ui/dashboard/orchestrator.py +735 -0
- scc_cli/ui/formatters.py +444 -0
- scc_cli/ui/gate.py +350 -0
- scc_cli/ui/git_interactive.py +869 -0
- scc_cli/ui/git_render.py +176 -0
- scc_cli/ui/help.py +157 -0
- scc_cli/ui/keys.py +615 -0
- scc_cli/ui/list_screen.py +437 -0
- scc_cli/ui/picker.py +763 -0
- scc_cli/ui/prompts.py +201 -0
- scc_cli/ui/quick_resume.py +116 -0
- scc_cli/ui/wizard.py +576 -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 +114 -0
- scc_cli/utils/ttl.py +376 -0
- scc_cli/validate.py +455 -0
- scc_cli-1.5.3.dist-info/METADATA +401 -0
- scc_cli-1.5.3.dist-info/RECORD +153 -0
- scc_cli-1.5.3.dist-info/WHEEL +4 -0
- scc_cli-1.5.3.dist-info/entry_points.txt +2 -0
- scc_cli-1.5.3.dist-info/licenses/LICENSE +21 -0
scc_cli/docker/launch.py
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Provide high-level Docker sandbox launch functions and settings injection.
|
|
3
|
+
|
|
4
|
+
Orchestrate the Docker sandbox lifecycle, combining primitives from
|
|
5
|
+
core.py and credential management from credentials.py.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, cast
|
|
14
|
+
|
|
15
|
+
from ..config import get_cache_dir
|
|
16
|
+
from ..console import err_line
|
|
17
|
+
from ..core.constants import SAFETY_NET_POLICY_FILENAME, SANDBOX_DATA_MOUNT, SANDBOX_DATA_VOLUME
|
|
18
|
+
from ..core.errors import SandboxLaunchError
|
|
19
|
+
from .core import (
|
|
20
|
+
build_command,
|
|
21
|
+
validate_container_filename,
|
|
22
|
+
)
|
|
23
|
+
from .credentials import (
|
|
24
|
+
_create_symlinks_in_container,
|
|
25
|
+
_preinit_credential_volume,
|
|
26
|
+
_start_migration_loop,
|
|
27
|
+
_sync_credentials_from_existing_containers,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
31
|
+
# Safety Net Policy Injection
|
|
32
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
# Default policy for when no org config exists (fail-safe to block mode)
|
|
35
|
+
DEFAULT_SAFETY_NET_POLICY: dict[str, Any] = {"action": "block"}
|
|
36
|
+
|
|
37
|
+
# Valid action values (prevents typo → weird behavior)
|
|
38
|
+
VALID_SAFETY_NET_ACTIONS: frozenset[str] = frozenset({"block", "warn", "allow"})
|
|
39
|
+
|
|
40
|
+
# Container path for policy (constant derived from mount point)
|
|
41
|
+
CONTAINER_POLICY_PATH = f"{SANDBOX_DATA_MOUNT}/{SAFETY_NET_POLICY_FILENAME}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def extract_safety_net_policy(org_config: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
45
|
+
"""Extract safety_net policy from org config for container injection.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
org_config: The resolved organization configuration, or None.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
The safety_net policy dict if present, None otherwise.
|
|
52
|
+
"""
|
|
53
|
+
if org_config is None:
|
|
54
|
+
return None
|
|
55
|
+
security = org_config.get("security")
|
|
56
|
+
if not isinstance(security, dict):
|
|
57
|
+
return None
|
|
58
|
+
safety_net = security.get("safety_net")
|
|
59
|
+
if not isinstance(safety_net, dict):
|
|
60
|
+
return None
|
|
61
|
+
return safety_net
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def validate_safety_net_policy(policy: dict[str, Any]) -> dict[str, Any]:
|
|
65
|
+
"""Validate and sanitize safety net policy, fail-closed on invalid values.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
policy: Raw policy dict from org config.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Validated policy dict. Missing or invalid 'action' values default to 'block'.
|
|
72
|
+
The result always contains an 'action' key.
|
|
73
|
+
"""
|
|
74
|
+
result = dict(policy) # shallow copy
|
|
75
|
+
action = result.get("action")
|
|
76
|
+
# Always set action: either keep valid value or default to "block" (fail-closed)
|
|
77
|
+
if action is None or action not in VALID_SAFETY_NET_ACTIONS:
|
|
78
|
+
result["action"] = "block" # fail-closed on missing or invalid
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_effective_safety_net_policy(org_config: dict[str, Any] | None) -> dict[str, Any]:
|
|
83
|
+
"""Get the safety net policy, falling back to default if not configured.
|
|
84
|
+
|
|
85
|
+
Always returns a policy dict - never None. This ensures the mount is always
|
|
86
|
+
present, avoiding sandbox reuse issues when policy is added later.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
org_config: The resolved organization configuration, or None.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
The validated safety_net policy dict from org config, or DEFAULT_SAFETY_NET_POLICY.
|
|
93
|
+
"""
|
|
94
|
+
custom_policy = extract_safety_net_policy(org_config)
|
|
95
|
+
if custom_policy is not None:
|
|
96
|
+
return validate_safety_net_policy(custom_policy)
|
|
97
|
+
return DEFAULT_SAFETY_NET_POLICY
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _write_policy_to_dir(policy: dict[str, Any], target_dir: Path) -> Path | None:
|
|
101
|
+
"""Write policy to a specific directory with atomic pattern.
|
|
102
|
+
|
|
103
|
+
Uses temp file + rename pattern for atomicity. Even if the process crashes
|
|
104
|
+
mid-write, readers will see either the old file or the complete new file.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
policy: Policy dict to write.
|
|
108
|
+
target_dir: Directory to write to.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
The absolute path to the policy file on success, None on failure.
|
|
112
|
+
"""
|
|
113
|
+
try:
|
|
114
|
+
target_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
115
|
+
except OSError:
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
policy_path = target_dir / SAFETY_NET_POLICY_FILENAME
|
|
119
|
+
content = json.dumps(policy, indent=2)
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
# Atomic write: temp file → fsync → rename → fsync dir
|
|
123
|
+
fd, temp_path_str = tempfile.mkstemp(
|
|
124
|
+
dir=target_dir,
|
|
125
|
+
prefix=".policy_",
|
|
126
|
+
suffix=".tmp",
|
|
127
|
+
)
|
|
128
|
+
temp_path = Path(temp_path_str)
|
|
129
|
+
try:
|
|
130
|
+
os.write(fd, content.encode("utf-8"))
|
|
131
|
+
os.fsync(fd)
|
|
132
|
+
finally:
|
|
133
|
+
os.close(fd)
|
|
134
|
+
|
|
135
|
+
os.chmod(temp_path, 0o600) # User read/write only
|
|
136
|
+
temp_path.replace(policy_path) # Atomic replace (cross-platform)
|
|
137
|
+
|
|
138
|
+
# fsync directory to ensure replace is durable
|
|
139
|
+
# O_DIRECTORY may not exist on all platforms (e.g., Windows)
|
|
140
|
+
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
141
|
+
try:
|
|
142
|
+
dir_fd = os.open(target_dir, flags)
|
|
143
|
+
try:
|
|
144
|
+
os.fsync(dir_fd)
|
|
145
|
+
finally:
|
|
146
|
+
os.close(dir_fd)
|
|
147
|
+
except OSError:
|
|
148
|
+
pass # Directory fsync is best-effort
|
|
149
|
+
|
|
150
|
+
return policy_path.resolve() # Return absolute path
|
|
151
|
+
except OSError:
|
|
152
|
+
# Clean up temp file if it exists
|
|
153
|
+
try:
|
|
154
|
+
if "temp_path" in locals():
|
|
155
|
+
temp_path.unlink(missing_ok=True)
|
|
156
|
+
except OSError:
|
|
157
|
+
pass
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _get_fallback_policy_dir() -> Path:
|
|
162
|
+
"""Get fallback directory for policy files.
|
|
163
|
+
|
|
164
|
+
Uses home-based path instead of tempfile.gettempdir() because:
|
|
165
|
+
- On macOS, /var/folders/... is often NOT Docker-shareable by default
|
|
166
|
+
- Home directory (/Users/... on macOS, /home/... on Linux) is always shared
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Path under user's home that's reliably Docker-mountable.
|
|
170
|
+
"""
|
|
171
|
+
return Path.home() / ".cache" / "scc-policy-fallback"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def write_safety_net_policy_to_host(policy: dict[str, Any]) -> Path | None:
|
|
175
|
+
"""Write safety net policy to host cache with atomic write pattern.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
policy: The safety_net policy dict to write.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
The absolute host path to the policy file (resolved for Docker Desktop),
|
|
182
|
+
or None on failure.
|
|
183
|
+
|
|
184
|
+
Note:
|
|
185
|
+
Uses atomic write (temp file + replace) to prevent partial reads
|
|
186
|
+
if container starts while file is being written.
|
|
187
|
+
Returns resolved absolute path for Docker Desktop compatibility.
|
|
188
|
+
|
|
189
|
+
If cache dir write fails, falls back to ~/.cache/scc-policy-fallback/
|
|
190
|
+
which is reliably Docker-shareable (under /Users on macOS, /home on Linux).
|
|
191
|
+
"""
|
|
192
|
+
# Primary: try cache directory (user's standard cache location)
|
|
193
|
+
cache_dir = get_cache_dir().resolve()
|
|
194
|
+
result = _write_policy_to_dir(policy, cache_dir)
|
|
195
|
+
|
|
196
|
+
if result is not None:
|
|
197
|
+
# Optional hygiene: delete old fallback files on success
|
|
198
|
+
_cleanup_fallback_policy_files()
|
|
199
|
+
return result
|
|
200
|
+
|
|
201
|
+
# Fallback: use home-based path (always Docker-shareable)
|
|
202
|
+
# Avoid tempfile.gettempdir() - on macOS it's /var/folders which may not be shared
|
|
203
|
+
fallback_dir = _get_fallback_policy_dir()
|
|
204
|
+
return _write_policy_to_dir(policy, fallback_dir)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _cleanup_fallback_policy_files() -> None:
|
|
208
|
+
"""Remove old fallback policy files (optional hygiene).
|
|
209
|
+
|
|
210
|
+
Called after successful cache dir write to clean up any stale fallback files.
|
|
211
|
+
Failures are silently ignored - this is purely optional hygiene.
|
|
212
|
+
"""
|
|
213
|
+
fallback_dir = _get_fallback_policy_dir()
|
|
214
|
+
fallback_file = fallback_dir / SAFETY_NET_POLICY_FILENAME
|
|
215
|
+
try:
|
|
216
|
+
fallback_file.unlink(missing_ok=True)
|
|
217
|
+
# Also try to remove the directory if empty
|
|
218
|
+
if fallback_dir.exists() and not any(fallback_dir.iterdir()):
|
|
219
|
+
fallback_dir.rmdir()
|
|
220
|
+
except OSError:
|
|
221
|
+
pass # Silently ignore - this is optional hygiene
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def run(
|
|
225
|
+
cmd: list[str],
|
|
226
|
+
ensure_credentials: bool = True,
|
|
227
|
+
org_config: dict[str, Any] | None = None,
|
|
228
|
+
container_workdir: Path | None = None,
|
|
229
|
+
) -> int:
|
|
230
|
+
"""
|
|
231
|
+
Execute the Docker command with optional org configuration.
|
|
232
|
+
|
|
233
|
+
This is a thin wrapper that calls run_sandbox() with extracted parameters.
|
|
234
|
+
When org_config is provided, the security.safety_net policy is extracted
|
|
235
|
+
and mounted read-only into the container for the scc-safety-net plugin.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
cmd: Command to execute (must be docker sandbox run format)
|
|
239
|
+
ensure_credentials: If True, use detached→symlink→exec pattern
|
|
240
|
+
org_config: Organization config dict. If provided, safety-net policy
|
|
241
|
+
is extracted and mounted. If None, default fail-safe policy is used.
|
|
242
|
+
container_workdir: Working directory for Claude inside container.
|
|
243
|
+
If None, uses the -w value from cmd (mount path).
|
|
244
|
+
For worktrees, this should be the actual workspace path so Claude
|
|
245
|
+
finds .claude/settings.local.json.
|
|
246
|
+
|
|
247
|
+
Raises:
|
|
248
|
+
SandboxLaunchError: If Docker command fails to start
|
|
249
|
+
"""
|
|
250
|
+
# Extract workspace from command if present
|
|
251
|
+
workspace = None
|
|
252
|
+
continue_session = False
|
|
253
|
+
resume = False
|
|
254
|
+
|
|
255
|
+
# Parse the command to extract workspace and flags
|
|
256
|
+
for i, arg in enumerate(cmd):
|
|
257
|
+
if arg == "-w" and i + 1 < len(cmd):
|
|
258
|
+
workspace = Path(cmd[i + 1])
|
|
259
|
+
elif arg == "-c":
|
|
260
|
+
continue_session = True
|
|
261
|
+
elif arg == "--resume":
|
|
262
|
+
resume = True
|
|
263
|
+
|
|
264
|
+
# Use the new synchronous run_sandbox function
|
|
265
|
+
return run_sandbox(
|
|
266
|
+
workspace=workspace,
|
|
267
|
+
continue_session=continue_session,
|
|
268
|
+
resume=resume,
|
|
269
|
+
ensure_credentials=ensure_credentials,
|
|
270
|
+
org_config=org_config,
|
|
271
|
+
container_workdir=container_workdir,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def run_sandbox(
|
|
276
|
+
workspace: Path | None = None,
|
|
277
|
+
continue_session: bool = False,
|
|
278
|
+
resume: bool = False,
|
|
279
|
+
ensure_credentials: bool = True,
|
|
280
|
+
org_config: dict[str, Any] | None = None,
|
|
281
|
+
container_workdir: Path | None = None,
|
|
282
|
+
) -> int:
|
|
283
|
+
"""
|
|
284
|
+
Run Claude in a Docker sandbox with credential persistence.
|
|
285
|
+
|
|
286
|
+
Uses SYNCHRONOUS detached→symlink→exec pattern to eliminate race condition:
|
|
287
|
+
1. Start container in DETACHED mode (no Claude running yet)
|
|
288
|
+
2. Create symlinks BEFORE Claude starts (race eliminated!)
|
|
289
|
+
3. Exec Claude interactively using docker exec
|
|
290
|
+
|
|
291
|
+
This replaces the previous fork-and-inject pattern which had a fundamental
|
|
292
|
+
race condition: parent became Docker at T+0, child created symlinks at T+2s,
|
|
293
|
+
but Claude read config at T+0 before symlinks existed.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
workspace: Path to mount as workspace (-w flag for docker sandbox run).
|
|
297
|
+
For worktrees, this is the common parent directory.
|
|
298
|
+
continue_session: Pass -c flag to Claude
|
|
299
|
+
resume: Pass --resume flag to Claude
|
|
300
|
+
ensure_credentials: If True, create credential symlinks
|
|
301
|
+
org_config: Organization config dict. If provided, security.safety_net
|
|
302
|
+
policy is extracted and mounted read-only into container for the
|
|
303
|
+
scc-safety-net plugin. If None, a default fail-safe policy is used.
|
|
304
|
+
container_workdir: Working directory for Claude inside container
|
|
305
|
+
(-w flag for docker exec). If None, defaults to workspace.
|
|
306
|
+
For worktrees, this should be the actual workspace path so Claude
|
|
307
|
+
finds .claude/settings.local.json.
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
Exit code from Docker process
|
|
311
|
+
|
|
312
|
+
Raises:
|
|
313
|
+
SandboxLaunchError: If Docker command fails to start
|
|
314
|
+
"""
|
|
315
|
+
try:
|
|
316
|
+
# STEP 0: Reset global settings to prevent plugin mixing across teams
|
|
317
|
+
# This ensures only workspace settings.local.json drives plugins.
|
|
318
|
+
# Called once per scc start flow, before container exec.
|
|
319
|
+
if not reset_global_settings():
|
|
320
|
+
err_line(
|
|
321
|
+
"Warning: Failed to reset global settings. "
|
|
322
|
+
"Plugin mixing may occur if switching teams."
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# ALWAYS write policy file and get host path (even without org config)
|
|
326
|
+
# This ensures the mount is present from first launch, avoiding
|
|
327
|
+
# sandbox reuse issues when safety-net is enabled later.
|
|
328
|
+
# If no org config, uses default {"action": "block"} (fail-safe).
|
|
329
|
+
effective_policy = get_effective_safety_net_policy(org_config)
|
|
330
|
+
policy_host_path = write_safety_net_policy_to_host(effective_policy)
|
|
331
|
+
# Note: policy_host_path may be None if write failed - build_command
|
|
332
|
+
# will handle this gracefully (no mount, plugin uses internal defaults)
|
|
333
|
+
|
|
334
|
+
if os.name != "nt" and ensure_credentials:
|
|
335
|
+
# STEP 1: Sync credentials from existing containers to volume
|
|
336
|
+
# This copies credentials from project A's container when starting project B
|
|
337
|
+
_sync_credentials_from_existing_containers()
|
|
338
|
+
|
|
339
|
+
# STEP 2: Pre-initialize volume files (prevents EOF race condition)
|
|
340
|
+
_preinit_credential_volume()
|
|
341
|
+
|
|
342
|
+
# STEP 3: Start container in DETACHED mode (no Claude running yet)
|
|
343
|
+
detached_cmd = build_command(
|
|
344
|
+
workspace=workspace,
|
|
345
|
+
detached=True,
|
|
346
|
+
policy_host_path=policy_host_path,
|
|
347
|
+
)
|
|
348
|
+
result = subprocess.run(
|
|
349
|
+
detached_cmd,
|
|
350
|
+
capture_output=True,
|
|
351
|
+
text=True,
|
|
352
|
+
timeout=60,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
if result.returncode != 0:
|
|
356
|
+
raise SandboxLaunchError(
|
|
357
|
+
user_message="Failed to create Docker sandbox",
|
|
358
|
+
command=" ".join(detached_cmd),
|
|
359
|
+
stderr=result.stderr,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
container_id = result.stdout.strip()
|
|
363
|
+
if not container_id:
|
|
364
|
+
raise SandboxLaunchError(
|
|
365
|
+
user_message="Docker sandbox returned empty container ID",
|
|
366
|
+
command=" ".join(detached_cmd),
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
# STEP 4: Create symlinks BEFORE Claude starts
|
|
370
|
+
# This is the KEY fix - symlinks exist BEFORE Claude reads config
|
|
371
|
+
_create_symlinks_in_container(container_id)
|
|
372
|
+
|
|
373
|
+
# STEP 5: Start background migration loop for first-time login
|
|
374
|
+
# This runs in background to capture OAuth tokens during login
|
|
375
|
+
_start_migration_loop(container_id)
|
|
376
|
+
|
|
377
|
+
# STEP 6: Exec Claude interactively (replaces current process)
|
|
378
|
+
# Claude binary is at /home/agent/.local/bin/claude
|
|
379
|
+
# Use -w to set working directory so Claude finds .claude/settings.local.json
|
|
380
|
+
# For worktrees: workspace is mount path (parent), container_workdir is actual workspace
|
|
381
|
+
exec_workdir = container_workdir if container_workdir else workspace
|
|
382
|
+
exec_cmd = ["docker", "exec", "-it", "-w", str(exec_workdir), container_id, "claude"]
|
|
383
|
+
|
|
384
|
+
# Add Claude-specific flags
|
|
385
|
+
if continue_session:
|
|
386
|
+
exec_cmd.append("-c")
|
|
387
|
+
elif resume:
|
|
388
|
+
exec_cmd.append("--resume")
|
|
389
|
+
|
|
390
|
+
# Replace current process with docker exec
|
|
391
|
+
os.execvp("docker", exec_cmd)
|
|
392
|
+
|
|
393
|
+
# If execvp returns, something went wrong
|
|
394
|
+
raise SandboxLaunchError(
|
|
395
|
+
user_message="Failed to exec into Docker sandbox",
|
|
396
|
+
command=" ".join(exec_cmd),
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
else:
|
|
400
|
+
# Non-credential mode or Windows: use legacy flow
|
|
401
|
+
# Policy injection still applies - mount is always present
|
|
402
|
+
# NOTE: Legacy path uses workspace for BOTH mount and CWD via -w flag.
|
|
403
|
+
# Worktrees require the exec path (credential mode) for separate mount/CWD.
|
|
404
|
+
cmd = build_command(
|
|
405
|
+
workspace=workspace,
|
|
406
|
+
continue_session=continue_session,
|
|
407
|
+
resume=resume,
|
|
408
|
+
detached=False,
|
|
409
|
+
policy_host_path=policy_host_path,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
if os.name != "nt":
|
|
413
|
+
os.execvp(cmd[0], cmd)
|
|
414
|
+
raise SandboxLaunchError(
|
|
415
|
+
user_message="Failed to start Docker sandbox",
|
|
416
|
+
command=" ".join(cmd),
|
|
417
|
+
)
|
|
418
|
+
else:
|
|
419
|
+
result = subprocess.run(cmd, text=True)
|
|
420
|
+
return result.returncode
|
|
421
|
+
|
|
422
|
+
except subprocess.TimeoutExpired:
|
|
423
|
+
raise SandboxLaunchError(
|
|
424
|
+
user_message="Docker sandbox creation timed out",
|
|
425
|
+
suggested_action="Check if Docker Desktop is running",
|
|
426
|
+
)
|
|
427
|
+
except FileNotFoundError:
|
|
428
|
+
raise SandboxLaunchError(
|
|
429
|
+
user_message="Command not found: docker",
|
|
430
|
+
suggested_action="Ensure Docker is installed and in your PATH",
|
|
431
|
+
)
|
|
432
|
+
except OSError as e:
|
|
433
|
+
raise SandboxLaunchError(
|
|
434
|
+
user_message=f"Failed to start Docker sandbox: {e}",
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def inject_file_to_sandbox_volume(filename: str, content: str) -> bool:
|
|
439
|
+
"""
|
|
440
|
+
Inject a file into the Docker sandbox persistent volume.
|
|
441
|
+
|
|
442
|
+
Uses a temporary alpine container to write to the sandbox data volume.
|
|
443
|
+
Files are written to /data/ which maps to /mnt/claude-data/ in the sandbox.
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
filename: Name of file to create (e.g., "settings.json", "scc-statusline.sh")
|
|
447
|
+
Must be a simple filename, no path separators allowed.
|
|
448
|
+
content: Content to write
|
|
449
|
+
|
|
450
|
+
Returns:
|
|
451
|
+
True if successful
|
|
452
|
+
|
|
453
|
+
Raises:
|
|
454
|
+
ValueError: If filename contains unsafe characters
|
|
455
|
+
"""
|
|
456
|
+
# Validate filename to prevent path traversal
|
|
457
|
+
filename = validate_container_filename(filename)
|
|
458
|
+
|
|
459
|
+
try:
|
|
460
|
+
# Escape content for shell (replace single quotes)
|
|
461
|
+
escaped_content = content.replace("'", "'\"'\"'")
|
|
462
|
+
|
|
463
|
+
# Use alpine to write to the persistent volume
|
|
464
|
+
result = subprocess.run(
|
|
465
|
+
[
|
|
466
|
+
"docker",
|
|
467
|
+
"run",
|
|
468
|
+
"--rm",
|
|
469
|
+
"-v",
|
|
470
|
+
f"{SANDBOX_DATA_VOLUME}:/data",
|
|
471
|
+
"alpine",
|
|
472
|
+
"sh",
|
|
473
|
+
"-c",
|
|
474
|
+
f"printf '%s' '{escaped_content}' > /data/{filename} && chmod +x /data/{filename}",
|
|
475
|
+
],
|
|
476
|
+
capture_output=True,
|
|
477
|
+
text=True,
|
|
478
|
+
timeout=30,
|
|
479
|
+
)
|
|
480
|
+
return result.returncode == 0
|
|
481
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
482
|
+
return False
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def get_sandbox_settings() -> dict[str, Any] | None:
|
|
486
|
+
"""
|
|
487
|
+
Return current settings from the Docker sandbox volume.
|
|
488
|
+
|
|
489
|
+
Returns:
|
|
490
|
+
Settings dict or None if not found
|
|
491
|
+
"""
|
|
492
|
+
try:
|
|
493
|
+
result = subprocess.run(
|
|
494
|
+
[
|
|
495
|
+
"docker",
|
|
496
|
+
"run",
|
|
497
|
+
"--rm",
|
|
498
|
+
"-v",
|
|
499
|
+
f"{SANDBOX_DATA_VOLUME}:/data",
|
|
500
|
+
"alpine",
|
|
501
|
+
"cat",
|
|
502
|
+
"/data/settings.json",
|
|
503
|
+
],
|
|
504
|
+
capture_output=True,
|
|
505
|
+
text=True,
|
|
506
|
+
timeout=30,
|
|
507
|
+
)
|
|
508
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
509
|
+
return cast(dict[Any, Any], json.loads(result.stdout))
|
|
510
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError, json.JSONDecodeError):
|
|
511
|
+
pass
|
|
512
|
+
return None
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def inject_settings(settings: dict[str, Any]) -> bool:
|
|
516
|
+
"""
|
|
517
|
+
Inject pre-built settings into the Docker sandbox volume.
|
|
518
|
+
|
|
519
|
+
This is the "dumb" settings injection function. docker.py does NOT know
|
|
520
|
+
about Claude Code settings format - it just merges and injects JSON.
|
|
521
|
+
|
|
522
|
+
Settings are merged with any existing settings in the sandbox volume
|
|
523
|
+
(e.g., status line config). New settings take precedence for conflicts.
|
|
524
|
+
|
|
525
|
+
Args:
|
|
526
|
+
settings: Pre-built settings dict (from claude_adapter.build_claude_settings)
|
|
527
|
+
|
|
528
|
+
Returns:
|
|
529
|
+
True if settings were injected successfully, False otherwise
|
|
530
|
+
"""
|
|
531
|
+
# Get existing settings from Docker volume (preserve status line, etc.)
|
|
532
|
+
existing_settings = get_sandbox_settings() or {}
|
|
533
|
+
|
|
534
|
+
# Merge settings with existing settings
|
|
535
|
+
# New settings take precedence for overlapping keys
|
|
536
|
+
merged_settings = {**existing_settings, **settings}
|
|
537
|
+
|
|
538
|
+
# Inject merged settings into Docker volume
|
|
539
|
+
return inject_file_to_sandbox_volume(
|
|
540
|
+
"settings.json",
|
|
541
|
+
json.dumps(merged_settings, indent=2),
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def reset_global_settings() -> bool:
|
|
546
|
+
"""
|
|
547
|
+
Reset global settings in Docker sandbox volume to empty state.
|
|
548
|
+
|
|
549
|
+
This prevents plugin mixing across teams by ensuring the volume doesn't
|
|
550
|
+
retain old plugin configurations. Workspace settings.local.json is the
|
|
551
|
+
single source of truth for plugins.
|
|
552
|
+
|
|
553
|
+
Called once per `scc start` flow, before container exec.
|
|
554
|
+
|
|
555
|
+
Returns:
|
|
556
|
+
True if reset successful, False otherwise
|
|
557
|
+
"""
|
|
558
|
+
# Write empty settings to the volume (overwrites any existing)
|
|
559
|
+
return inject_file_to_sandbox_volume("settings.json", "{}")
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def get_or_create_container(
|
|
563
|
+
workspace: Path | None,
|
|
564
|
+
branch: str | None = None,
|
|
565
|
+
profile: str | None = None,
|
|
566
|
+
force_new: bool = False,
|
|
567
|
+
continue_session: bool = False,
|
|
568
|
+
env_vars: dict[str, str] | None = None,
|
|
569
|
+
) -> tuple[list[str], bool]:
|
|
570
|
+
"""
|
|
571
|
+
Build a Docker sandbox run command.
|
|
572
|
+
|
|
573
|
+
Note: Docker sandboxes are ephemeral by design - they don't support container
|
|
574
|
+
re-use patterns like traditional `docker run`. Each invocation creates a new
|
|
575
|
+
sandbox instance. The branch, profile, force_new, and env_vars parameters are
|
|
576
|
+
kept for API compatibility but are not used.
|
|
577
|
+
|
|
578
|
+
Args:
|
|
579
|
+
workspace: Path to workspace (-w flag for sandbox)
|
|
580
|
+
branch: Git branch name (unused - sandboxes don't support naming)
|
|
581
|
+
profile: Team profile (unused - sandboxes don't support labels)
|
|
582
|
+
force_new: Force new container (unused - sandboxes are always new)
|
|
583
|
+
continue_session: Pass -c flag to Claude
|
|
584
|
+
env_vars: Environment variables (unused - sandboxes handle auth)
|
|
585
|
+
|
|
586
|
+
Returns:
|
|
587
|
+
Tuple of (command_to_run, is_resume)
|
|
588
|
+
- is_resume is always False for sandboxes (no resume support)
|
|
589
|
+
"""
|
|
590
|
+
# Docker sandbox doesn't support container re-use - always create new
|
|
591
|
+
cmd = build_command(
|
|
592
|
+
workspace=workspace,
|
|
593
|
+
continue_session=continue_session,
|
|
594
|
+
)
|
|
595
|
+
return cmd, False
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Provide system diagnostics and health checks for SCC-CLI.
|
|
2
|
+
|
|
3
|
+
Offer comprehensive health checks for all prerequisites needed to run
|
|
4
|
+
Claude Code in Docker sandboxes.
|
|
5
|
+
|
|
6
|
+
Philosophy: "Fast feedback, clear guidance"
|
|
7
|
+
- Check all prerequisites quickly
|
|
8
|
+
- Provide clear pass/fail indicators
|
|
9
|
+
- Offer actionable fix suggestions
|
|
10
|
+
|
|
11
|
+
Package Structure:
|
|
12
|
+
- types.py: Data structures (CheckResult, DoctorResult, JsonValidationResult)
|
|
13
|
+
- checks.py: Individual health check functions
|
|
14
|
+
- render.py: Orchestration and Rich terminal rendering
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
# Re-export the config module for backward compatibility with tests
|
|
18
|
+
from scc_cli import config
|
|
19
|
+
|
|
20
|
+
# Import all check functions from checks/ package
|
|
21
|
+
from scc_cli.doctor.checks import (
|
|
22
|
+
_escape_rich,
|
|
23
|
+
check_cache_readable,
|
|
24
|
+
check_cache_ttl_status,
|
|
25
|
+
check_config_directory,
|
|
26
|
+
check_credential_injection,
|
|
27
|
+
check_docker,
|
|
28
|
+
check_docker_running,
|
|
29
|
+
check_docker_sandbox,
|
|
30
|
+
check_exception_stores,
|
|
31
|
+
check_git,
|
|
32
|
+
check_git_version_for_worktrees,
|
|
33
|
+
check_marketplace_auth_available,
|
|
34
|
+
check_migration_status,
|
|
35
|
+
check_org_config_reachable,
|
|
36
|
+
check_proxy_environment,
|
|
37
|
+
check_user_config_valid,
|
|
38
|
+
check_workspace_path,
|
|
39
|
+
check_worktree_branch_conflicts,
|
|
40
|
+
check_worktree_health,
|
|
41
|
+
check_wsl2,
|
|
42
|
+
format_code_frame,
|
|
43
|
+
get_json_error_hints,
|
|
44
|
+
load_cached_org_config,
|
|
45
|
+
run_all_checks,
|
|
46
|
+
validate_json_file,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Import orchestration and rendering functions from render.py
|
|
50
|
+
from scc_cli.doctor.render import (
|
|
51
|
+
build_doctor_json_data,
|
|
52
|
+
is_first_run,
|
|
53
|
+
quick_check,
|
|
54
|
+
render_doctor_compact,
|
|
55
|
+
render_doctor_results,
|
|
56
|
+
render_quick_status,
|
|
57
|
+
run_doctor,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Import types from types.py
|
|
61
|
+
from scc_cli.doctor.types import CheckResult, DoctorResult, JsonValidationResult
|
|
62
|
+
|
|
63
|
+
__all__ = [
|
|
64
|
+
# Config module (for backward compatibility with tests)
|
|
65
|
+
"config",
|
|
66
|
+
# Dataclasses
|
|
67
|
+
"CheckResult",
|
|
68
|
+
"DoctorResult",
|
|
69
|
+
"JsonValidationResult",
|
|
70
|
+
# JSON validation helpers
|
|
71
|
+
"validate_json_file",
|
|
72
|
+
"format_code_frame",
|
|
73
|
+
"_escape_rich",
|
|
74
|
+
"get_json_error_hints",
|
|
75
|
+
# Check functions
|
|
76
|
+
"check_git",
|
|
77
|
+
"check_git_version_for_worktrees",
|
|
78
|
+
"check_docker",
|
|
79
|
+
"check_docker_sandbox",
|
|
80
|
+
"check_docker_running",
|
|
81
|
+
"check_wsl2",
|
|
82
|
+
"check_workspace_path",
|
|
83
|
+
"check_worktree_health",
|
|
84
|
+
"check_worktree_branch_conflicts",
|
|
85
|
+
"check_user_config_valid",
|
|
86
|
+
"check_config_directory",
|
|
87
|
+
"load_cached_org_config",
|
|
88
|
+
"check_org_config_reachable",
|
|
89
|
+
"check_marketplace_auth_available",
|
|
90
|
+
"check_credential_injection",
|
|
91
|
+
"check_cache_readable",
|
|
92
|
+
"check_cache_ttl_status",
|
|
93
|
+
"check_migration_status",
|
|
94
|
+
"check_exception_stores",
|
|
95
|
+
"check_proxy_environment",
|
|
96
|
+
"run_all_checks",
|
|
97
|
+
# Orchestration and rendering
|
|
98
|
+
"run_doctor",
|
|
99
|
+
"build_doctor_json_data",
|
|
100
|
+
"render_doctor_results",
|
|
101
|
+
"render_doctor_compact",
|
|
102
|
+
"render_quick_status",
|
|
103
|
+
"quick_check",
|
|
104
|
+
"is_first_run",
|
|
105
|
+
]
|