scc-cli 1.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of scc-cli might be problematic. Click here for more details.
- scc_cli/__init__.py +15 -0
- scc_cli/audit/__init__.py +37 -0
- scc_cli/audit/parser.py +191 -0
- scc_cli/audit/reader.py +180 -0
- scc_cli/auth.py +145 -0
- scc_cli/claude_adapter.py +485 -0
- scc_cli/cli.py +259 -0
- scc_cli/cli_admin.py +706 -0
- scc_cli/cli_audit.py +245 -0
- scc_cli/cli_common.py +166 -0
- scc_cli/cli_config.py +527 -0
- scc_cli/cli_exceptions.py +705 -0
- scc_cli/cli_helpers.py +244 -0
- scc_cli/cli_init.py +272 -0
- scc_cli/cli_launch.py +1454 -0
- scc_cli/cli_org.py +1428 -0
- scc_cli/cli_support.py +322 -0
- scc_cli/cli_team.py +892 -0
- scc_cli/cli_worktree.py +865 -0
- scc_cli/config.py +583 -0
- scc_cli/console.py +562 -0
- scc_cli/constants.py +79 -0
- scc_cli/contexts.py +377 -0
- scc_cli/deprecation.py +54 -0
- scc_cli/deps.py +189 -0
- scc_cli/docker/__init__.py +127 -0
- scc_cli/docker/core.py +466 -0
- scc_cli/docker/credentials.py +726 -0
- scc_cli/docker/launch.py +604 -0
- scc_cli/doctor/__init__.py +99 -0
- scc_cli/doctor/checks.py +1074 -0
- scc_cli/doctor/render.py +346 -0
- scc_cli/doctor/types.py +66 -0
- scc_cli/errors.py +288 -0
- scc_cli/evaluation/__init__.py +27 -0
- scc_cli/evaluation/apply_exceptions.py +207 -0
- scc_cli/evaluation/evaluate.py +97 -0
- scc_cli/evaluation/models.py +80 -0
- scc_cli/exit_codes.py +55 -0
- scc_cli/git.py +1521 -0
- scc_cli/json_command.py +166 -0
- scc_cli/json_output.py +96 -0
- scc_cli/kinds.py +62 -0
- scc_cli/marketplace/__init__.py +123 -0
- scc_cli/marketplace/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 +723 -0
- scc_cli/marketplace/normalize.py +548 -0
- scc_cli/marketplace/render.py +257 -0
- scc_cli/marketplace/resolve.py +459 -0
- scc_cli/marketplace/schema.py +506 -0
- scc_cli/marketplace/sync.py +260 -0
- scc_cli/marketplace/team_cache.py +195 -0
- scc_cli/marketplace/team_fetch.py +688 -0
- scc_cli/marketplace/trust.py +244 -0
- scc_cli/models/__init__.py +41 -0
- scc_cli/models/exceptions.py +273 -0
- scc_cli/models/plugin_audit.py +434 -0
- scc_cli/org_templates.py +269 -0
- scc_cli/output_mode.py +167 -0
- scc_cli/panels.py +113 -0
- scc_cli/platform.py +350 -0
- scc_cli/profiles.py +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/sessions.py +425 -0
- scc_cli/setup.py +588 -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 +382 -0
- scc_cli/templates/__init__.py +2 -0
- scc_cli/templates/org/__init__.py +0 -0
- scc_cli/templates/org/minimal.json +19 -0
- scc_cli/templates/org/reference.json +74 -0
- scc_cli/templates/org/strict.json +38 -0
- scc_cli/templates/org/teams.json +42 -0
- scc_cli/templates/statusline.sh +75 -0
- scc_cli/theme.py +348 -0
- scc_cli/ui/__init__.py +124 -0
- scc_cli/ui/branding.py +68 -0
- scc_cli/ui/chrome.py +395 -0
- scc_cli/ui/dashboard/__init__.py +62 -0
- scc_cli/ui/dashboard/_dashboard.py +677 -0
- scc_cli/ui/dashboard/loaders.py +395 -0
- scc_cli/ui/dashboard/models.py +184 -0
- scc_cli/ui/dashboard/orchestrator.py +390 -0
- scc_cli/ui/formatters.py +443 -0
- scc_cli/ui/gate.py +350 -0
- scc_cli/ui/help.py +157 -0
- scc_cli/ui/keys.py +538 -0
- scc_cli/ui/list_screen.py +431 -0
- scc_cli/ui/picker.py +700 -0
- scc_cli/ui/prompts.py +200 -0
- scc_cli/ui/wizard.py +675 -0
- scc_cli/update.py +680 -0
- scc_cli/utils/__init__.py +39 -0
- scc_cli/utils/fixit.py +264 -0
- scc_cli/utils/fuzzy.py +124 -0
- scc_cli/utils/locks.py +101 -0
- scc_cli/utils/ttl.py +376 -0
- scc_cli/validate.py +455 -0
- scc_cli-1.4.1.dist-info/METADATA +369 -0
- scc_cli-1.4.1.dist-info/RECORD +113 -0
- scc_cli-1.4.1.dist-info/WHEEL +4 -0
- scc_cli-1.4.1.dist-info/entry_points.txt +2 -0
- scc_cli-1.4.1.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 ..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
|