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,726 @@
1
+ """
2
+ Credential persistence subsystem for Docker sandbox.
3
+
4
+ ===============================================================================
5
+ CREDENTIAL PERSISTENCE ARCHITECTURE (DO NOT MODIFY)
6
+ ===============================================================================
7
+
8
+ PROBLEM: OAuth credentials lost when switching projects. Claude reads config
9
+ before symlinks are created (race condition).
10
+
11
+ SOLUTION (Synchronous Detached Pattern):
12
+ 1. docker sandbox run -d -w /path claude → Creates container, returns ID
13
+ 2. docker exec <id> <symlink_script> → Creates symlinks while idle
14
+ 3. docker exec -it <id> claude → Runs Claude after symlinks exist
15
+
16
+ CRITICAL - DO NOT CHANGE:
17
+ - Agent name `claude` is REQUIRED even in detached mode (-d)!
18
+ Wrong: docker sandbox run -d -w /path
19
+ Right: docker sandbox run -d -w /path claude
20
+ - Session flags (-c, --resume) passed via docker exec, NOT container creation
21
+
22
+ See run_sandbox() in launch.py for implementation.
23
+ ===============================================================================
24
+ """
25
+
26
+ import json
27
+ import os
28
+ import subprocess
29
+ import tempfile
30
+ from pathlib import Path
31
+
32
+ from ..core.constants import OAUTH_CREDENTIAL_KEY, SANDBOX_DATA_VOLUME
33
+ from .core import _list_all_sandbox_containers, list_running_sandboxes
34
+
35
+
36
+ def _preinit_credential_volume() -> None:
37
+ """
38
+ Pre-initialize credential volume files BEFORE container starts.
39
+
40
+ This prevents "JSON Parse error: Unexpected EOF" race condition:
41
+ 1. Docker sandbox creates symlinks to volume immediately on start
42
+ 2. Claude Code reads symlinked files immediately
43
+ 3. If volume files don't exist, Claude sees EOF error
44
+
45
+ Solution: Ensure volume has valid JSON files BEFORE starting container.
46
+ Uses a temporary alpine container to initialize the volume.
47
+
48
+ CRITICAL: Files must be owned by uid 1000 (agent user) and writable,
49
+ otherwise Claude Code cannot write OAuth tokens to .credentials.json!
50
+ """
51
+ init_cmd = (
52
+ # Create files with empty JSON object if missing or empty
53
+ "[ -s /data/.claude.json ] || echo '{}' > /data/.claude.json; "
54
+ "[ -s /data/credentials.json ] || echo '{}' > /data/credentials.json; "
55
+ "[ -s /data/.credentials.json ] || echo '{}' > /data/.credentials.json; "
56
+ # ALWAYS fix ownership to agent user (uid 1000) - handles existing volumes
57
+ # with wrong permissions from earlier versions
58
+ "chown 1000:1000 /data/.claude.json /data/credentials.json /data/.credentials.json 2>/dev/null; "
59
+ # ALWAYS set writable permissions (needed for OAuth token writes)
60
+ "chmod 666 /data/.claude.json /data/credentials.json /data/.credentials.json 2>/dev/null"
61
+ )
62
+
63
+ try:
64
+ subprocess.run(
65
+ [
66
+ "docker",
67
+ "run",
68
+ "--rm",
69
+ "-v",
70
+ f"{SANDBOX_DATA_VOLUME}:/data",
71
+ "alpine",
72
+ "sh",
73
+ "-c",
74
+ init_cmd,
75
+ ],
76
+ capture_output=True,
77
+ text=True,
78
+ timeout=30,
79
+ )
80
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
81
+ # If pre-init fails, continue anyway - sandbox might still work
82
+ pass
83
+
84
+
85
+ def _check_volume_has_credentials() -> bool:
86
+ """
87
+ Check whether the Docker volume already has valid OAuth credentials.
88
+
89
+ The volume is the source of truth. If it has credentials from a
90
+ previous session, we don't need to copy from containers.
91
+
92
+ Returns:
93
+ True if volume has valid OAuth credentials
94
+ """
95
+ try:
96
+ result = subprocess.run(
97
+ [
98
+ "docker",
99
+ "run",
100
+ "--rm",
101
+ "-v",
102
+ f"{SANDBOX_DATA_VOLUME}:/data",
103
+ "alpine",
104
+ "cat",
105
+ "/data/.credentials.json",
106
+ ],
107
+ capture_output=True,
108
+ text=True,
109
+ timeout=10,
110
+ )
111
+
112
+ if result.returncode != 0 or not result.stdout.strip():
113
+ return False
114
+
115
+ # Validate JSON and check for OAuth tokens
116
+ try:
117
+ creds = json.loads(result.stdout)
118
+ return bool(creds and creds.get(OAUTH_CREDENTIAL_KEY))
119
+ except json.JSONDecodeError:
120
+ return False
121
+
122
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
123
+ return False
124
+
125
+
126
+ def _copy_credentials_from_container(container_id: str, is_running: bool) -> bool:
127
+ """
128
+ Copy OAuth credentials from a container to the persistent volume.
129
+
130
+ For RUNNING containers: uses docker exec
131
+ For STOPPED containers: uses docker cp (the key insight!)
132
+
133
+ Args:
134
+ container_id: The container ID to copy from
135
+ is_running: Whether the container is currently running
136
+
137
+ Returns:
138
+ True if credentials were found and copied successfully
139
+ """
140
+ if is_running:
141
+ # Running container: use docker exec to cat the file
142
+ try:
143
+ result = subprocess.run(
144
+ [
145
+ "docker",
146
+ "exec",
147
+ container_id,
148
+ "cat",
149
+ "/home/agent/.claude/.credentials.json",
150
+ ],
151
+ capture_output=True,
152
+ text=True,
153
+ timeout=10,
154
+ )
155
+
156
+ if result.returncode != 0 or not result.stdout.strip():
157
+ return False
158
+
159
+ # Validate JSON
160
+ try:
161
+ creds = json.loads(result.stdout)
162
+ if not creds or not creds.get(OAUTH_CREDENTIAL_KEY):
163
+ return False
164
+ except json.JSONDecodeError:
165
+ return False
166
+
167
+ # Write to volume
168
+ escaped = result.stdout.replace("'", "'\"'\"'")
169
+ subprocess.run(
170
+ [
171
+ "docker",
172
+ "run",
173
+ "--rm",
174
+ "-v",
175
+ f"{SANDBOX_DATA_VOLUME}:/data",
176
+ "alpine",
177
+ "sh",
178
+ "-c",
179
+ f"printf '%s' '{escaped}' > /data/.credentials.json && "
180
+ "chown 1000:1000 /data/.credentials.json && chmod 666 /data/.credentials.json",
181
+ ],
182
+ capture_output=True,
183
+ timeout=30,
184
+ )
185
+
186
+ # Also copy .claude.json
187
+ result2 = subprocess.run(
188
+ ["docker", "exec", container_id, "cat", "/home/agent/.claude.json"],
189
+ capture_output=True,
190
+ text=True,
191
+ timeout=10,
192
+ )
193
+ if result2.returncode == 0 and result2.stdout.strip():
194
+ escaped2 = result2.stdout.replace("'", "'\"'\"'")
195
+ subprocess.run(
196
+ [
197
+ "docker",
198
+ "run",
199
+ "--rm",
200
+ "-v",
201
+ f"{SANDBOX_DATA_VOLUME}:/data",
202
+ "alpine",
203
+ "sh",
204
+ "-c",
205
+ f"printf '%s' '{escaped2}' > /data/.claude.json && "
206
+ "chown 1000:1000 /data/.claude.json && chmod 666 /data/.claude.json",
207
+ ],
208
+ capture_output=True,
209
+ timeout=30,
210
+ )
211
+
212
+ return True
213
+
214
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
215
+ return False
216
+
217
+ else:
218
+ # STOPPED container: use docker cp (THE KEY FIX!)
219
+ # docker cp works on stopped containers, docker exec does not
220
+ try:
221
+ with tempfile.TemporaryDirectory() as tmpdir:
222
+ creds_path = Path(tmpdir) / ".credentials.json"
223
+ claude_path = Path(tmpdir) / ".claude.json"
224
+
225
+ # Copy .credentials.json from stopped container
226
+ result = subprocess.run(
227
+ [
228
+ "docker",
229
+ "cp",
230
+ f"{container_id}:/home/agent/.claude/.credentials.json",
231
+ str(creds_path),
232
+ ],
233
+ capture_output=True,
234
+ text=True,
235
+ timeout=10,
236
+ )
237
+
238
+ if result.returncode != 0 or not creds_path.exists():
239
+ return False
240
+
241
+ # Validate credentials
242
+ try:
243
+ content = creds_path.read_text()
244
+ creds = json.loads(content)
245
+ if not creds or not creds.get(OAUTH_CREDENTIAL_KEY):
246
+ return False
247
+ except (json.JSONDecodeError, OSError):
248
+ return False
249
+
250
+ # Write to volume using alpine container
251
+ escaped = content.replace("'", "'\"'\"'")
252
+ subprocess.run(
253
+ [
254
+ "docker",
255
+ "run",
256
+ "--rm",
257
+ "-v",
258
+ f"{SANDBOX_DATA_VOLUME}:/data",
259
+ "alpine",
260
+ "sh",
261
+ "-c",
262
+ f"printf '%s' '{escaped}' > /data/.credentials.json && "
263
+ "chown 1000:1000 /data/.credentials.json && chmod 666 /data/.credentials.json",
264
+ ],
265
+ capture_output=True,
266
+ timeout=30,
267
+ )
268
+
269
+ # Also try .claude.json
270
+ result2 = subprocess.run(
271
+ [
272
+ "docker",
273
+ "cp",
274
+ f"{container_id}:/home/agent/.claude.json",
275
+ str(claude_path),
276
+ ],
277
+ capture_output=True,
278
+ text=True,
279
+ timeout=10,
280
+ )
281
+
282
+ if result2.returncode == 0 and claude_path.exists():
283
+ try:
284
+ content2 = claude_path.read_text()
285
+ escaped2 = content2.replace("'", "'\"'\"'")
286
+ subprocess.run(
287
+ [
288
+ "docker",
289
+ "run",
290
+ "--rm",
291
+ "-v",
292
+ f"{SANDBOX_DATA_VOLUME}:/data",
293
+ "alpine",
294
+ "sh",
295
+ "-c",
296
+ f"printf '%s' '{escaped2}' > /data/.claude.json && "
297
+ "chown 1000:1000 /data/.claude.json && chmod 666 /data/.claude.json",
298
+ ],
299
+ capture_output=True,
300
+ timeout=30,
301
+ )
302
+ except OSError:
303
+ pass # .claude.json is optional
304
+
305
+ return True
306
+
307
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
308
+ return False
309
+
310
+
311
+ def _sync_credentials_from_existing_containers() -> bool:
312
+ """
313
+ Sync credentials from existing containers to volume BEFORE starting new container.
314
+
315
+ This is the KEY to cross-project credential persistence:
316
+ 1. Check if volume already has credentials (source of truth)
317
+ 2. If not, check ALL containers (running AND stopped)
318
+ 3. Use docker cp for stopped containers (docker exec only works on running)
319
+
320
+ The critical insight: when user does /exit, the container STOPS.
321
+ docker exec doesn't work on stopped containers, but docker cp DOES!
322
+
323
+ Returns:
324
+ True if credentials exist in volume (either already or after sync)
325
+ """
326
+ # Step 1: Check if volume already has credentials
327
+ if _check_volume_has_credentials():
328
+ return True # Volume is source of truth, nothing to do
329
+
330
+ # Step 2: Get ALL containers (running AND stopped)
331
+ containers = _list_all_sandbox_containers()
332
+ if not containers:
333
+ return False
334
+
335
+ # Step 3: Try to copy credentials from each container
336
+ for container in containers:
337
+ is_running = "Up" in container.status
338
+ if _copy_credentials_from_container(container.id, is_running):
339
+ return True # Successfully synced
340
+
341
+ return False
342
+
343
+
344
+ def _create_symlinks_in_container(container_id: str) -> bool:
345
+ """
346
+ Create credential symlinks directly in a running container.
347
+
348
+ NON-DESTRUCTIVE approach:
349
+ - Docker sandbox creates some symlinks automatically (.claude.json, settings.json)
350
+ - We only create symlinks that are MISSING or point to WRONG target
351
+ - Never delete Docker's working symlinks (prevents race conditions)
352
+
353
+ Args:
354
+ container_id: The container ID to create symlinks in
355
+
356
+ Returns:
357
+ True if all required symlinks exist
358
+ """
359
+ try:
360
+ # Step 1: Ensure directory exists
361
+ subprocess.run(
362
+ ["docker", "exec", container_id, "mkdir", "-p", "/home/agent/.claude"],
363
+ capture_output=True,
364
+ timeout=5,
365
+ )
366
+
367
+ # Step 2: Create symlinks only if missing or pointing to wrong target
368
+ symlinks = [
369
+ # (source on volume, target in container)
370
+ # .credentials.json is the OAuth file - Docker does NOT create this
371
+ ("/mnt/claude-data/.credentials.json", "/home/agent/.claude/.credentials.json"),
372
+ # .claude.json - Docker creates this, but we verify it's correct
373
+ ("/mnt/claude-data/.claude.json", "/home/agent/.claude.json"),
374
+ # credentials.json (API key) - Docker does NOT create this
375
+ ("/mnt/claude-data/credentials.json", "/home/agent/.claude/credentials.json"),
376
+ ]
377
+
378
+ for src, dst in symlinks:
379
+ # Check if symlink already exists and points to correct target
380
+ check = subprocess.run(
381
+ ["docker", "exec", container_id, "readlink", dst],
382
+ capture_output=True,
383
+ text=True,
384
+ timeout=5,
385
+ )
386
+ if check.returncode == 0 and check.stdout.strip() == src:
387
+ # Symlink already correct, skip (don't touch Docker's symlinks)
388
+ continue
389
+
390
+ # Symlink missing or wrong - create it (ln -sfn is atomic)
391
+ # -s = symbolic, -f = force (overwrite), -n = no-dereference
392
+ result = subprocess.run(
393
+ ["docker", "exec", container_id, "ln", "-sfn", src, dst],
394
+ capture_output=True,
395
+ timeout=5,
396
+ )
397
+ if result.returncode != 0:
398
+ return False
399
+
400
+ return True
401
+
402
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
403
+ return False
404
+
405
+
406
+ def _migrate_credentials_to_volume(container_id: str) -> bool:
407
+ """
408
+ Migrate any regular credential files from container to volume.
409
+
410
+ If credentials exist as regular files (not symlinks) in the container,
411
+ copy them to the volume before creating symlinks.
412
+
413
+ Args:
414
+ container_id: The container ID to migrate from
415
+
416
+ Returns:
417
+ True if migration succeeded or was not needed
418
+ """
419
+ try:
420
+ # Check if .credentials.json is a regular file (not symlink)
421
+ result = subprocess.run(
422
+ [
423
+ "docker",
424
+ "exec",
425
+ container_id,
426
+ "sh",
427
+ "-c",
428
+ "[ -f /home/agent/.claude/.credentials.json ] && "
429
+ "[ ! -L /home/agent/.claude/.credentials.json ] && "
430
+ "cat /home/agent/.claude/.credentials.json",
431
+ ],
432
+ capture_output=True,
433
+ text=True,
434
+ timeout=10,
435
+ )
436
+
437
+ if result.returncode == 0 and result.stdout.strip():
438
+ # Found regular file with content - copy to volume
439
+ content = result.stdout
440
+ try:
441
+ creds = json.loads(content)
442
+ if creds and creds.get(OAUTH_CREDENTIAL_KEY):
443
+ # Valid OAuth credentials - copy to volume
444
+ escaped = content.replace("'", "'\"'\"'")
445
+ subprocess.run(
446
+ [
447
+ "docker",
448
+ "run",
449
+ "--rm",
450
+ "-v",
451
+ f"{SANDBOX_DATA_VOLUME}:/data",
452
+ "alpine",
453
+ "sh",
454
+ "-c",
455
+ f"printf '%s' '{escaped}' > /data/.credentials.json && "
456
+ "chown 1000:1000 /data/.credentials.json",
457
+ ],
458
+ capture_output=True,
459
+ timeout=30,
460
+ )
461
+ except json.JSONDecodeError:
462
+ pass
463
+
464
+ # Also check .claude.json
465
+ result2 = subprocess.run(
466
+ [
467
+ "docker",
468
+ "exec",
469
+ container_id,
470
+ "sh",
471
+ "-c",
472
+ "[ -f /home/agent/.claude.json ] && "
473
+ "[ ! -L /home/agent/.claude.json ] && "
474
+ "cat /home/agent/.claude.json",
475
+ ],
476
+ capture_output=True,
477
+ text=True,
478
+ timeout=10,
479
+ )
480
+
481
+ if result2.returncode == 0 and result2.stdout.strip():
482
+ escaped = result2.stdout.replace("'", "'\"'\"'")
483
+ subprocess.run(
484
+ [
485
+ "docker",
486
+ "run",
487
+ "--rm",
488
+ "-v",
489
+ f"{SANDBOX_DATA_VOLUME}:/data",
490
+ "alpine",
491
+ "sh",
492
+ "-c",
493
+ f"printf '%s' '{escaped}' > /data/.claude.json && "
494
+ "chown 1000:1000 /data/.claude.json",
495
+ ],
496
+ capture_output=True,
497
+ timeout=30,
498
+ )
499
+
500
+ return True
501
+
502
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
503
+ return False
504
+
505
+
506
+ def _ensure_credentials_symlink(existing_sandbox_ids: set[str] | None = None) -> bool:
507
+ """
508
+ Create credential symlinks from container paths to persistent volume.
509
+
510
+ Docker Desktop's sandbox creates symlinks to /mnt/claude-data/ for the
511
+ FIRST sandbox only. When switching workspaces, subsequent sandboxes
512
+ don't get these symlinks, causing credentials to not persist.
513
+
514
+ This function:
515
+ 1. Waits for the NEW container to start
516
+ 2. Creates symlinks IMMEDIATELY once found
517
+ 3. Runs migration loop to capture OAuth tokens during first login
518
+
519
+ Args:
520
+ existing_sandbox_ids: Set of container IDs that existed before we started
521
+ the new sandbox. Used to identify the NEW container (not in this set).
522
+
523
+ Returns:
524
+ True if symlinks were created successfully
525
+ """
526
+ import datetime
527
+ import time
528
+
529
+ debug_log = "/tmp/scc-sandbox-debug.log"
530
+
531
+ def _debug(msg: str) -> None:
532
+ """Write debug message to log file."""
533
+ try:
534
+ with open(debug_log, "a") as f:
535
+ f.write(f"{datetime.datetime.now().isoformat()} [symlink] {msg}\n")
536
+ except Exception:
537
+ pass
538
+
539
+ startup_timeout = 60 # Max 60 seconds to find the container
540
+ migration_interval = 5 # Check every 5 seconds for new credentials
541
+ container_id = None
542
+
543
+ _debug(f"Starting, existing_ids={existing_sandbox_ids}")
544
+
545
+ # Phase 1: Wait for NEW container to start
546
+ start_time = time.time()
547
+ while time.time() - start_time < startup_timeout:
548
+ try:
549
+ sandboxes = list_running_sandboxes()
550
+ sandbox_ids = [s.id for s in sandboxes]
551
+ _debug(f"Found sandboxes: {sandbox_ids}")
552
+
553
+ if existing_sandbox_ids:
554
+ new_sandboxes = [s for s in sandboxes if s.id not in existing_sandbox_ids]
555
+ if new_sandboxes:
556
+ container_id = new_sandboxes[0].id
557
+ _debug(f"Found NEW container: {container_id}")
558
+ break
559
+ elif sandboxes:
560
+ container_id = sandboxes[0].id
561
+ _debug(f"Found container (no existing): {container_id}")
562
+ break
563
+ except Exception as e:
564
+ _debug(f"Exception in sandbox list: {type(e).__name__}: {e}")
565
+ time.sleep(1) # Check frequently during startup
566
+
567
+ if not container_id:
568
+ _debug(f"FAILED: No container found after {startup_timeout}s")
569
+ return False
570
+
571
+ # Phase 2: Create symlinks IMMEDIATELY
572
+ # This is the critical fix - create symlinks as soon as container starts
573
+ _debug(f"Creating symlinks in container {container_id}...")
574
+ symlink_result = _create_symlinks_in_container(container_id)
575
+ _debug(f"Symlink creation result: {symlink_result}")
576
+
577
+ # Phase 3: Run migration loop UNTIL container stops
578
+ # This captures OAuth tokens during first login and migrates them to volume
579
+ loop_count = 0
580
+ while True:
581
+ try:
582
+ sandboxes = list_running_sandboxes()
583
+ if not any(s.id == container_id for s in sandboxes):
584
+ _debug(
585
+ f"Container {container_id} stopped, exiting loop after {loop_count} iterations"
586
+ )
587
+ break # Container stopped
588
+
589
+ # Migrate any new credentials to volume
590
+ _migrate_credentials_to_volume(container_id)
591
+
592
+ # Re-create symlinks (in case Claude wrote regular files)
593
+ _create_symlinks_in_container(container_id)
594
+
595
+ loop_count += 1
596
+
597
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
598
+ _debug(f"Loop exception: {type(e).__name__}: {e}")
599
+ break
600
+
601
+ time.sleep(migration_interval)
602
+
603
+ _debug(f"Completed successfully, loop ran {loop_count} times")
604
+ return True
605
+
606
+
607
+ def _start_migration_loop(container_id: str) -> None:
608
+ """
609
+ Start background process to capture OAuth tokens during first login.
610
+
611
+ This is still needed for FIRST LOGIN only - when user logs in for the
612
+ first time, Claude writes tokens to container filesystem. This loop
613
+ migrates them to the persistent volume.
614
+
615
+ For subsequent projects, credentials are already in volume from step 1.
616
+
617
+ Args:
618
+ container_id: The container to monitor and migrate from
619
+ """
620
+ pid = os.fork()
621
+ if pid == 0:
622
+ # Child process: daemonize and run migration loop
623
+ import datetime
624
+ import time
625
+
626
+ debug_log = "/tmp/scc-sandbox-debug.log"
627
+
628
+ def _debug(msg: str) -> None:
629
+ try:
630
+ with open(debug_log, "a") as f:
631
+ f.write(f"{datetime.datetime.now().isoformat()} [migration] {msg}\n")
632
+ except Exception:
633
+ pass
634
+
635
+ try:
636
+ # Detach from terminal
637
+ os.setsid()
638
+
639
+ # Redirect FDs to /dev/null
640
+ devnull = os.open(os.devnull, os.O_RDWR)
641
+ os.dup2(devnull, 0)
642
+ os.dup2(devnull, 1)
643
+ os.dup2(devnull, 2)
644
+ os.close(devnull)
645
+
646
+ _debug(f"Migration loop started for {container_id}")
647
+
648
+ # Run migration loop until container stops
649
+ loop_count = 0
650
+ while True:
651
+ try:
652
+ sandboxes = list_running_sandboxes()
653
+ if not any(s.id == container_id for s in sandboxes):
654
+ _debug(f"Container {container_id} stopped after {loop_count} loops")
655
+ break
656
+
657
+ # Migrate any new credentials to volume
658
+ _migrate_credentials_to_volume(container_id)
659
+ loop_count += 1
660
+
661
+ except Exception as e:
662
+ _debug(f"Loop error: {type(e).__name__}: {e}")
663
+ break
664
+
665
+ time.sleep(5)
666
+
667
+ _debug("Migration loop completed")
668
+ os._exit(0)
669
+
670
+ except Exception as e:
671
+ _debug(f"Migration FAILED: {type(e).__name__}: {e}")
672
+ os._exit(1)
673
+
674
+
675
+ def prepare_sandbox_volume_for_credentials() -> bool:
676
+ """
677
+ Prepare the Docker sandbox volume for credential persistence.
678
+
679
+ The Docker sandbox volume has a permissions issue where files are created as
680
+ root:root, but the sandbox runs as agent (uid=1000). This function:
681
+ 1. Creates .claude.json (OAuth) if it doesn't exist (owned by uid 1000)
682
+ 2. Creates credentials.json (API keys) if it doesn't exist (owned by uid 1000)
683
+ 3. Fixes directory permissions so agent user can write
684
+ 4. Ensures existing files are writable by agent
685
+
686
+ OAuth credentials (Claude Max subscription) are stored in .claude.json,
687
+ while API keys are stored in credentials.json. Both need proper permissions.
688
+
689
+ Returns:
690
+ True if preparation successful
691
+ """
692
+ try:
693
+ # Fix permissions on the volume directory and create credential files
694
+ # The agent user in the sandbox has uid=1000
695
+ result = subprocess.run(
696
+ [
697
+ "docker",
698
+ "run",
699
+ "--rm",
700
+ "-v",
701
+ f"{SANDBOX_DATA_VOLUME}:/data",
702
+ "alpine",
703
+ "sh",
704
+ "-c",
705
+ # Fix directory permissions
706
+ "chmod 777 /data && "
707
+ # Prepare .claude.json (OAuth credentials - Claude Max subscription)
708
+ "touch /data/.claude.json && "
709
+ "chown 1000:1000 /data/.claude.json && "
710
+ "chmod 666 /data/.claude.json && "
711
+ # Prepare credentials.json (API keys)
712
+ "touch /data/credentials.json && "
713
+ "chown 1000:1000 /data/credentials.json && "
714
+ "chmod 666 /data/credentials.json && "
715
+ # Fix settings.json permissions if it exists
716
+ "chown 1000:1000 /data/settings.json 2>/dev/null; "
717
+ "chmod 666 /data/settings.json 2>/dev/null; "
718
+ "echo 'Volume prepared for credentials'",
719
+ ],
720
+ capture_output=True,
721
+ text=True,
722
+ timeout=30,
723
+ )
724
+ return result.returncode == 0
725
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
726
+ return False