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.

Files changed (153) hide show
  1. scc_cli/__init__.py +15 -0
  2. scc_cli/audit/__init__.py +37 -0
  3. scc_cli/audit/parser.py +191 -0
  4. scc_cli/audit/reader.py +180 -0
  5. scc_cli/auth.py +145 -0
  6. scc_cli/claude_adapter.py +485 -0
  7. scc_cli/cli.py +311 -0
  8. scc_cli/cli_common.py +190 -0
  9. scc_cli/cli_helpers.py +244 -0
  10. scc_cli/commands/__init__.py +20 -0
  11. scc_cli/commands/admin.py +708 -0
  12. scc_cli/commands/audit.py +246 -0
  13. scc_cli/commands/config.py +528 -0
  14. scc_cli/commands/exceptions.py +696 -0
  15. scc_cli/commands/init.py +272 -0
  16. scc_cli/commands/launch/__init__.py +73 -0
  17. scc_cli/commands/launch/app.py +1247 -0
  18. scc_cli/commands/launch/render.py +309 -0
  19. scc_cli/commands/launch/sandbox.py +135 -0
  20. scc_cli/commands/launch/workspace.py +339 -0
  21. scc_cli/commands/org/__init__.py +49 -0
  22. scc_cli/commands/org/_builders.py +264 -0
  23. scc_cli/commands/org/app.py +41 -0
  24. scc_cli/commands/org/import_cmd.py +267 -0
  25. scc_cli/commands/org/init_cmd.py +269 -0
  26. scc_cli/commands/org/schema_cmd.py +76 -0
  27. scc_cli/commands/org/status_cmd.py +157 -0
  28. scc_cli/commands/org/update_cmd.py +330 -0
  29. scc_cli/commands/org/validate_cmd.py +138 -0
  30. scc_cli/commands/support.py +323 -0
  31. scc_cli/commands/team.py +910 -0
  32. scc_cli/commands/worktree/__init__.py +72 -0
  33. scc_cli/commands/worktree/_helpers.py +57 -0
  34. scc_cli/commands/worktree/app.py +170 -0
  35. scc_cli/commands/worktree/container_commands.py +385 -0
  36. scc_cli/commands/worktree/context_commands.py +61 -0
  37. scc_cli/commands/worktree/session_commands.py +128 -0
  38. scc_cli/commands/worktree/worktree_commands.py +734 -0
  39. scc_cli/config.py +647 -0
  40. scc_cli/confirm.py +20 -0
  41. scc_cli/console.py +562 -0
  42. scc_cli/contexts.py +394 -0
  43. scc_cli/core/__init__.py +68 -0
  44. scc_cli/core/constants.py +101 -0
  45. scc_cli/core/errors.py +297 -0
  46. scc_cli/core/exit_codes.py +91 -0
  47. scc_cli/core/workspace.py +57 -0
  48. scc_cli/deprecation.py +54 -0
  49. scc_cli/deps.py +189 -0
  50. scc_cli/docker/__init__.py +127 -0
  51. scc_cli/docker/core.py +467 -0
  52. scc_cli/docker/credentials.py +726 -0
  53. scc_cli/docker/launch.py +595 -0
  54. scc_cli/doctor/__init__.py +105 -0
  55. scc_cli/doctor/checks/__init__.py +166 -0
  56. scc_cli/doctor/checks/cache.py +314 -0
  57. scc_cli/doctor/checks/config.py +107 -0
  58. scc_cli/doctor/checks/environment.py +182 -0
  59. scc_cli/doctor/checks/json_helpers.py +157 -0
  60. scc_cli/doctor/checks/organization.py +264 -0
  61. scc_cli/doctor/checks/worktree.py +278 -0
  62. scc_cli/doctor/render.py +365 -0
  63. scc_cli/doctor/types.py +66 -0
  64. scc_cli/evaluation/__init__.py +27 -0
  65. scc_cli/evaluation/apply_exceptions.py +207 -0
  66. scc_cli/evaluation/evaluate.py +97 -0
  67. scc_cli/evaluation/models.py +80 -0
  68. scc_cli/git.py +84 -0
  69. scc_cli/json_command.py +166 -0
  70. scc_cli/json_output.py +159 -0
  71. scc_cli/kinds.py +65 -0
  72. scc_cli/marketplace/__init__.py +123 -0
  73. scc_cli/marketplace/adapter.py +74 -0
  74. scc_cli/marketplace/compute.py +377 -0
  75. scc_cli/marketplace/constants.py +87 -0
  76. scc_cli/marketplace/managed.py +135 -0
  77. scc_cli/marketplace/materialize.py +846 -0
  78. scc_cli/marketplace/normalize.py +548 -0
  79. scc_cli/marketplace/render.py +281 -0
  80. scc_cli/marketplace/resolve.py +459 -0
  81. scc_cli/marketplace/schema.py +506 -0
  82. scc_cli/marketplace/sync.py +279 -0
  83. scc_cli/marketplace/team_cache.py +195 -0
  84. scc_cli/marketplace/team_fetch.py +689 -0
  85. scc_cli/marketplace/trust.py +244 -0
  86. scc_cli/models/__init__.py +41 -0
  87. scc_cli/models/exceptions.py +273 -0
  88. scc_cli/models/plugin_audit.py +434 -0
  89. scc_cli/org_templates.py +269 -0
  90. scc_cli/output_mode.py +167 -0
  91. scc_cli/panels.py +113 -0
  92. scc_cli/platform.py +350 -0
  93. scc_cli/profiles.py +960 -0
  94. scc_cli/remote.py +443 -0
  95. scc_cli/schemas/__init__.py +1 -0
  96. scc_cli/schemas/org-v1.schema.json +456 -0
  97. scc_cli/schemas/team-config.v1.schema.json +163 -0
  98. scc_cli/services/__init__.py +1 -0
  99. scc_cli/services/git/__init__.py +79 -0
  100. scc_cli/services/git/branch.py +151 -0
  101. scc_cli/services/git/core.py +216 -0
  102. scc_cli/services/git/hooks.py +108 -0
  103. scc_cli/services/git/worktree.py +444 -0
  104. scc_cli/services/workspace/__init__.py +36 -0
  105. scc_cli/services/workspace/resolver.py +223 -0
  106. scc_cli/services/workspace/suspicious.py +200 -0
  107. scc_cli/sessions.py +425 -0
  108. scc_cli/setup.py +589 -0
  109. scc_cli/source_resolver.py +470 -0
  110. scc_cli/stats.py +378 -0
  111. scc_cli/stores/__init__.py +13 -0
  112. scc_cli/stores/exception_store.py +251 -0
  113. scc_cli/subprocess_utils.py +88 -0
  114. scc_cli/teams.py +383 -0
  115. scc_cli/templates/__init__.py +2 -0
  116. scc_cli/templates/org/__init__.py +0 -0
  117. scc_cli/templates/org/minimal.json +19 -0
  118. scc_cli/templates/org/reference.json +74 -0
  119. scc_cli/templates/org/strict.json +38 -0
  120. scc_cli/templates/org/teams.json +42 -0
  121. scc_cli/templates/statusline.sh +75 -0
  122. scc_cli/theme.py +348 -0
  123. scc_cli/ui/__init__.py +154 -0
  124. scc_cli/ui/branding.py +68 -0
  125. scc_cli/ui/chrome.py +401 -0
  126. scc_cli/ui/dashboard/__init__.py +62 -0
  127. scc_cli/ui/dashboard/_dashboard.py +794 -0
  128. scc_cli/ui/dashboard/loaders.py +452 -0
  129. scc_cli/ui/dashboard/models.py +185 -0
  130. scc_cli/ui/dashboard/orchestrator.py +735 -0
  131. scc_cli/ui/formatters.py +444 -0
  132. scc_cli/ui/gate.py +350 -0
  133. scc_cli/ui/git_interactive.py +869 -0
  134. scc_cli/ui/git_render.py +176 -0
  135. scc_cli/ui/help.py +157 -0
  136. scc_cli/ui/keys.py +615 -0
  137. scc_cli/ui/list_screen.py +437 -0
  138. scc_cli/ui/picker.py +763 -0
  139. scc_cli/ui/prompts.py +201 -0
  140. scc_cli/ui/quick_resume.py +116 -0
  141. scc_cli/ui/wizard.py +576 -0
  142. scc_cli/update.py +680 -0
  143. scc_cli/utils/__init__.py +39 -0
  144. scc_cli/utils/fixit.py +264 -0
  145. scc_cli/utils/fuzzy.py +124 -0
  146. scc_cli/utils/locks.py +114 -0
  147. scc_cli/utils/ttl.py +376 -0
  148. scc_cli/validate.py +455 -0
  149. scc_cli-1.5.3.dist-info/METADATA +401 -0
  150. scc_cli-1.5.3.dist-info/RECORD +153 -0
  151. scc_cli-1.5.3.dist-info/WHEEL +4 -0
  152. scc_cli-1.5.3.dist-info/entry_points.txt +2 -0
  153. scc_cli-1.5.3.dist-info/licenses/LICENSE +21 -0
@@ -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
+ ]