aisc-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. aisc/VERSION +1 -0
  2. aisc/__init__.py +49 -0
  3. aisc/__main__.py +6 -0
  4. aisc/adapters/__init__.py +1 -0
  5. aisc/adapters/config_reader.py +215 -0
  6. aisc/adapters/container_registry.py +718 -0
  7. aisc/adapters/data_root_store.py +296 -0
  8. aisc/adapters/docker_.py +1183 -0
  9. aisc/adapters/docker_gateway.py +1244 -0
  10. aisc/adapters/docker_sdk_backed.py +274 -0
  11. aisc/adapters/maintenance_lock.py +97 -0
  12. aisc/adapters/state_file.py +184 -0
  13. aisc/adapters/system.py +101 -0
  14. aisc/adapters/windows_config_reader.py +249 -0
  15. aisc/adapters/workspace_lease_store.py +189 -0
  16. aisc/application/__init__.py +1 -0
  17. aisc/application/artifact.py +265 -0
  18. aisc/application/bundle_fetch.py +836 -0
  19. aisc/application/cc_switch_provider.py +245 -0
  20. aisc/application/cc_switch_resolver.py +363 -0
  21. aisc/application/config_service.py +452 -0
  22. aisc/application/conversation.py +516 -0
  23. aisc/application/data_migration.py +440 -0
  24. aisc/application/data_root.py +275 -0
  25. aisc/application/docker_lifecycle.py +659 -0
  26. aisc/application/doctor.py +821 -0
  27. aisc/application/legacy_scan.py +252 -0
  28. aisc/application/network_subscription.py +642 -0
  29. aisc/application/profile_service.py +105 -0
  30. aisc/application/provider.py +92 -0
  31. aisc/application/repair.py +387 -0
  32. aisc/application/resources.py +270 -0
  33. aisc/application/runtime.py +1826 -0
  34. aisc/application/session.py +320 -0
  35. aisc/application/tool_versions.py +210 -0
  36. aisc/application/toolchain.py +91 -0
  37. aisc/application/update.py +286 -0
  38. aisc/application/usage.py +305 -0
  39. aisc/application/version.py +133 -0
  40. aisc/application/web_gateway.py +459 -0
  41. aisc/application/workspace_reconcile.py +318 -0
  42. aisc/applog.py +103 -0
  43. aisc/cli/__init__.py +1 -0
  44. aisc/cli/build_progress_parser.py +217 -0
  45. aisc/cli/commands/__init__.py +1 -0
  46. aisc/cli/commands/agents.py +117 -0
  47. aisc/cli/commands/artifact.py +210 -0
  48. aisc/cli/commands/build.py +566 -0
  49. aisc/cli/commands/bundle.py +107 -0
  50. aisc/cli/commands/cc_switch.py +158 -0
  51. aisc/cli/commands/config.py +104 -0
  52. aisc/cli/commands/container.py +787 -0
  53. aisc/cli/commands/conversation.py +72 -0
  54. aisc/cli/commands/data_root.py +84 -0
  55. aisc/cli/commands/logs.py +89 -0
  56. aisc/cli/commands/network.py +132 -0
  57. aisc/cli/commands/profile.py +52 -0
  58. aisc/cli/commands/provider.py +49 -0
  59. aisc/cli/commands/run.py +494 -0
  60. aisc/cli/commands/runs.py +125 -0
  61. aisc/cli/commands/runtime.py +450 -0
  62. aisc/cli/commands/serve.py +550 -0
  63. aisc/cli/commands/serve_fs.py +369 -0
  64. aisc/cli/commands/session.py +167 -0
  65. aisc/cli/commands/update.py +145 -0
  66. aisc/cli/commands/usage.py +74 -0
  67. aisc/cli/commands/wizard.py +130 -0
  68. aisc/cli/commands/workspaces.py +162 -0
  69. aisc/cli/help_i18n.py +385 -0
  70. aisc/cli/main.py +2926 -0
  71. aisc/cli/output.py +239 -0
  72. aisc/cli/tutorial.py +109 -0
  73. aisc/domain/__init__.py +1 -0
  74. aisc/domain/artifacts.py +279 -0
  75. aisc/domain/cc_switch_release.py +303 -0
  76. aisc/domain/config.py +64 -0
  77. aisc/domain/data_migration.py +259 -0
  78. aisc/domain/data_root.py +124 -0
  79. aisc/domain/docker_ownership.py +148 -0
  80. aisc/domain/gateway.py +168 -0
  81. aisc/domain/models.py +674 -0
  82. aisc/domain/toolchain.py +72 -0
  83. aisc/domain/web_services.py +355 -0
  84. aisc/domain/workspace_lease.py +139 -0
  85. aisc/schemas/__init__.py +1 -0
  86. aisc/schemas/config_schema.py +145 -0
  87. aisc_cli-0.1.0.dist-info/METADATA +420 -0
  88. aisc_cli-0.1.0.dist-info/RECORD +92 -0
  89. aisc_cli-0.1.0.dist-info/WHEEL +5 -0
  90. aisc_cli-0.1.0.dist-info/entry_points.txt +2 -0
  91. aisc_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  92. aisc_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1183 @@
1
+ """Docker adapter — unified ``DockerExecutor`` protocol with preflight,
2
+ image inspection, and build/run execution.
3
+
4
+ **All** docker operations go through the executor. Application / command
5
+ code never calls ``subprocess.run`` directly.
6
+
7
+ Protocol methods
8
+ ----------------
9
+ - ``preflight()`` → ``DockerPreflightResult``
10
+ - ``inspect_image()`` → ``ImageInspectResult`` (structured, never swallows)
11
+ - ``run_captured()`` → ``ProcessResult`` (capture stdout/stderr)
12
+ - ``run_streaming()`` → ``int`` (inherit streams, return exit code)
13
+
14
+ ``shell=True`` is **never** used.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import queue
21
+ import select
22
+ import socket
23
+ import time
24
+ import shlex
25
+ import signal
26
+ import shutil
27
+ import subprocess
28
+ import sys
29
+ import threading
30
+ from dataclasses import dataclass
31
+ from pathlib import Path
32
+ from typing import Any, Callable, Dict, List, Optional, Protocol, runtime_checkable
33
+
34
+ from aisc.domain.models import (
35
+ BuildPlan,
36
+ DockerPreflightResult,
37
+ ImageInspectResult,
38
+ ImageInspectStatus,
39
+ ProcessResult,
40
+ RunPlan,
41
+ )
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Executor protocol — single injectable abstraction
46
+ # ---------------------------------------------------------------------------
47
+
48
+ def _poll_resize_step(resize_file, last_size, apply_resize):
49
+ """One resize-file poll step (G-02): read ``"<cols> <rows>"``; when it
50
+ changed, call *apply_resize* with ``(cols, rows)`` and return the new
51
+ size, otherwise return *last_size* unchanged. A missing/garbage file is
52
+ tolerated (returns *last_size*).
53
+
54
+ Hoisted out of the interactive watch threads so the update semantics are
55
+ unit-testable. The previous INLINE version assigned the closed-over
56
+ ``last_size`` without ``nonlocal`` -- Python made it a thread-local, so
57
+ every iteration raised UnboundLocalError (silently swallowed by the
58
+ broad except) and NO resize after the sidecar's initial read ever
59
+ reached the container (B-05: terminals stuck at their startup size).
60
+ """
61
+ try:
62
+ content = open(resize_file).read().strip().split()
63
+ if len(content) == 2:
64
+ cur = (int(content[0]), int(content[1]))
65
+ if cur != last_size:
66
+ apply_resize(cur)
67
+ return cur
68
+ except Exception: # noqa: BLE001
69
+ pass
70
+ return last_size
71
+
72
+
73
+ @runtime_checkable
74
+ class DockerExecutor(Protocol):
75
+ """Narrow interface for all Docker operations.
76
+
77
+ Every method maps to one or more ``docker`` CLI invocations.
78
+ """
79
+
80
+ def preflight(self) -> DockerPreflightResult:
81
+ """Check Docker CLI availability + daemon reachability via
82
+ ``docker info`` (8 s timeout)."""
83
+
84
+ def inspect_image(self, image_name: str) -> ImageInspectResult:
85
+ """Check whether *image_name* exists locally via
86
+ ``docker image inspect`` (10 s timeout). Structured result —
87
+ never returns bare ``bool``."""
88
+
89
+ def run_captured(self, docker_argv: List[str],
90
+ *, timeout: Optional[float] = None,
91
+ input_text: Optional[str] = None) -> ProcessResult:
92
+ """Execute ``docker <argv>`` with captured stdout / stderr.
93
+
94
+ ``input_text`` (Stage 8d) pipes a string to the child's stdin — the
95
+ cc-switch provider data plane's secret channel (never argv)."""
96
+
97
+ def run_streaming(self, docker_argv: List[str],
98
+ *, timeout: Optional[float] = None) -> ProcessResult:
99
+ """Execute ``docker <argv>`` with inherited stdin / stdout / stderr.
100
+ Returns a ``ProcessResult`` with exit code captured, stderr empty."""
101
+
102
+ def open_interactive(
103
+ self,
104
+ container: str,
105
+ argv: List[str],
106
+ env: Optional[Dict[str, str]] = None,
107
+ ) -> ProcessResult:
108
+ """Open an interactive TTY session via the Docker SDK so the exec pty
109
+ can be resized with ``exec_resize`` (G-02: the docker CLI's exec pty is
110
+ frozen at the spawn size). Raw tty stream: stdout forwarded to fd 1,
111
+ stdin forwarded to the socket, terminal-size watcher forwards changes.
112
+ Returns the agent's exit code from exec_inspect."""
113
+
114
+ def run_non_interactive(self, docker_argv: List[str],
115
+ *, timeout: Optional[float] = None) -> ProcessResult:
116
+ """Execute ``docker <argv>`` with DEVNULL stdin, inherited stdout / stderr.
117
+ For ``--non-interactive`` mode."""
118
+
119
+ def run_streaming_captured(self, docker_argv: List[str],
120
+ on_chunk: "Callable[[str, str], None]",
121
+ *, timeout: Optional[float] = None) -> ProcessResult:
122
+ """Execute ``docker <argv>`` streaming stdout/stderr chunks to
123
+ *on_chunk(stream, chunk)*. Used by ``build --events`` for real-time
124
+ ``build.output`` events (not end-of-build replay). The child runs in its
125
+ own process group so a cancel can kill it without signaling the CLI."""
126
+
127
+ # Container operations
128
+ def list_containers(self, all: bool = False) -> ProcessResult:
129
+ """List containers via ``docker ps`` (or ``docker ps -a`` if all=True)."""
130
+
131
+ def stop_container(self, container_name: str, timeout: int = 10) -> ProcessResult:
132
+ """Stop a container via ``docker stop``."""
133
+
134
+ def remove_container(self, container_name: str, force: bool = False) -> ProcessResult:
135
+ """Remove a container via ``docker rm`` (with -f if force=True)."""
136
+
137
+ def inspect_container(self, container_name: str) -> ProcessResult:
138
+ """Inspect a container via ``docker inspect``."""
139
+
140
+ # Image operations
141
+ def list_images(self) -> ProcessResult:
142
+ """List images via ``docker images``."""
143
+
144
+ def remove_image(self, image_name: str, force: bool = False) -> ProcessResult:
145
+ """Remove an image via ``docker rmi`` (with -f if force=True)."""
146
+
147
+ def pull_image(self, image_name: str) -> ProcessResult:
148
+ """Pull an image via ``docker pull``."""
149
+
150
+ def tag_image(self, source: str, target: str) -> ProcessResult:
151
+ """Tag an image via ``docker tag``."""
152
+
153
+
154
+ # Factory functions are below; Protocol methods don't have bodies.
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # Process-tree kill helper (cross-platform)
158
+ # ---------------------------------------------------------------------------
159
+
160
+ def _kill_child(proc: subprocess.Popen) -> None:
161
+ """Kill *proc* and its whole child tree, never raising.
162
+
163
+ POSIX: SIGKILL the child's process group (docker build subprocesses must
164
+ not outlive the CLI). Windows: ``taskkill /T /F`` tree kill with
165
+ ``proc.kill()`` as fallback. Safe to call from exception handlers —
166
+ neither ``os.killpg`` nor ``signal.SIGKILL`` exist on Windows, so the
167
+ fallbacks keep the cleanup path from crashing and masking the original
168
+ exception.
169
+ """
170
+ if os.name == "posix":
171
+ try:
172
+ os.killpg(proc.pid, signal.SIGKILL)
173
+ return
174
+ except OSError:
175
+ pass
176
+ else:
177
+ try:
178
+ subprocess.run(
179
+ ["taskkill", "/PID", str(proc.pid), "/T", "/F"],
180
+ capture_output=True, timeout=5,
181
+ )
182
+ except Exception:
183
+ pass
184
+ try:
185
+ proc.kill()
186
+ except Exception:
187
+ pass
188
+
189
+
190
+ # ---------------------------------------------------------------------------
191
+ # Real Docker executor (production)
192
+ # ---------------------------------------------------------------------------
193
+
194
+ class RealDockerExecutor:
195
+ """Real ``docker`` subprocess executor — all methods use ``subprocess``."""
196
+
197
+ _PREFLIGHT_TIMEOUT = 8.0
198
+ _INSPECT_TIMEOUT = 10.0
199
+
200
+ # Windows install locations to try when the CLI is not on PATH. A fresh
201
+ # winget install only lands in the user PATH after Explorer re-reads the
202
+ # environment, so a Workbench launched straight from the installer inherits
203
+ # a stale PATH and ``shutil.which`` misses it (TODO 20260806 line 76).
204
+ # The per-user "Install for me" layout (Programs\DockerDesktop) leads —
205
+ # KI-6: its absence made direct terminal CLI runs see no running engine.
206
+ _WINDOWS_FALLBACK_PATHS = (
207
+ r"%LOCALAPPDATA%\Programs\DockerDesktop\resources\bin\docker.exe",
208
+ r"C:\Program Files\Docker\Docker\resources\bin\docker.exe",
209
+ r"%LOCALAPPDATA%\Docker\Docker\resources\bin\docker.exe",
210
+ )
211
+
212
+ def __init__(self, docker_path: Optional[str] = None):
213
+ self._docker_path: Optional[str] = docker_path
214
+
215
+ def _resolve_path(self) -> Optional[str]:
216
+ if self._docker_path is not None:
217
+ return self._docker_path
218
+ self._docker_path = shutil.which("docker")
219
+ if self._docker_path is None and os.name == "nt":
220
+ for candidate in self._WINDOWS_FALLBACK_PATHS:
221
+ expanded = os.path.expandvars(candidate)
222
+ if os.path.isfile(expanded):
223
+ self._docker_path = expanded
224
+ break
225
+ return self._docker_path
226
+
227
+ def _subprocess_env(self) -> dict:
228
+ """Env for docker subprocesses: the resolved docker dir prepended to
229
+ PATH so credential helpers next to the docker CLI (e.g.
230
+ ``docker-credential-desktop.exe``) resolve even when the parent
231
+ process inherited a stale PATH (Workbench launched straight from the
232
+ installer; S4.1.b)."""
233
+ env = os.environ.copy()
234
+ dp = self._resolve_path()
235
+ if dp:
236
+ env["PATH"] = os.path.dirname(dp) + os.pathsep + env.get("PATH", "")
237
+ return env
238
+
239
+ # ------------------------------------------------------------------
240
+ # preflight
241
+ # ------------------------------------------------------------------
242
+
243
+ def preflight(self) -> DockerPreflightResult:
244
+ docker_path = self._resolve_path()
245
+ if docker_path is None:
246
+ return DockerPreflightResult(
247
+ docker_path="", available=False, reason="cli_not_found",
248
+ )
249
+
250
+ try:
251
+ proc = subprocess.run(
252
+ [docker_path, "info"],
253
+ capture_output=True, text=True,
254
+ encoding="utf-8", errors="replace",
255
+ timeout=self._PREFLIGHT_TIMEOUT,
256
+ env=self._subprocess_env(),
257
+ )
258
+ except FileNotFoundError:
259
+ return DockerPreflightResult(
260
+ docker_path=docker_path, available=False, reason="cli_not_found",
261
+ )
262
+ except subprocess.TimeoutExpired:
263
+ return DockerPreflightResult(
264
+ docker_path=docker_path, available=False, reason="daemon_unreachable",
265
+ )
266
+ except PermissionError:
267
+ return DockerPreflightResult(
268
+ docker_path=docker_path, available=False, reason="permission_denied",
269
+ )
270
+ except OSError as exc:
271
+ return DockerPreflightResult(
272
+ docker_path=docker_path, available=False,
273
+ reason="daemon_unreachable",
274
+ )
275
+
276
+ if proc.returncode != 0:
277
+ stderr_lower = (proc.stderr or "").lower()
278
+ if "permission denied" in stderr_lower:
279
+ return DockerPreflightResult(
280
+ docker_path=docker_path, available=False,
281
+ reason="permission_denied",
282
+ )
283
+ return DockerPreflightResult(
284
+ docker_path=docker_path, available=False,
285
+ reason="daemon_unreachable",
286
+ )
287
+
288
+ return DockerPreflightResult(
289
+ docker_path=docker_path, available=True, reason="ok",
290
+ )
291
+
292
+ # ------------------------------------------------------------------
293
+ # inspect_image
294
+ # ------------------------------------------------------------------
295
+
296
+ def inspect_image(self, image_name: str) -> ImageInspectResult:
297
+ docker_path = self._resolve_path()
298
+ if docker_path is None:
299
+ return ImageInspectResult(
300
+ status=ImageInspectStatus.DOCKER_UNAVAILABLE,
301
+ image=image_name,
302
+ message="Docker CLI not found",
303
+ )
304
+
305
+ try:
306
+ proc = subprocess.run(
307
+ [docker_path, "image", "inspect", image_name],
308
+ capture_output=True, text=True,
309
+ timeout=self._INSPECT_TIMEOUT,
310
+ encoding="utf-8", errors="replace",
311
+ env=self._subprocess_env(),
312
+ )
313
+ except FileNotFoundError:
314
+ return ImageInspectResult(
315
+ status=ImageInspectStatus.DOCKER_UNAVAILABLE,
316
+ image=image_name, message="Docker CLI not found",
317
+ )
318
+ except subprocess.TimeoutExpired:
319
+ return ImageInspectResult(
320
+ status=ImageInspectStatus.TIMEOUT,
321
+ image=image_name, message="docker image inspect timed out",
322
+ )
323
+ except PermissionError:
324
+ return ImageInspectResult(
325
+ status=ImageInspectStatus.PERMISSION_DENIED,
326
+ image=image_name, message="Permission denied accessing Docker daemon",
327
+ )
328
+ except OSError as exc:
329
+ return ImageInspectResult(
330
+ status=ImageInspectStatus.ERROR,
331
+ image=image_name, message=f"Command error: {exc}",
332
+ )
333
+
334
+ if proc.returncode == 0:
335
+ # 容器随镜像同步更新 (KI-4 挂账): harvest the content-addressed
336
+ # .Id for the image-sync conflict check. Existence stays the
337
+ # primary answer — an unparseable body degrades to "" (unknown),
338
+ # never to a failure.
339
+ image_id = ""
340
+ try:
341
+ import json as _json
342
+ docs = _json.loads(proc.stdout or "[]")
343
+ if isinstance(docs, list) and docs and isinstance(docs[0], dict):
344
+ image_id = str(docs[0].get("Id") or "")
345
+ except (ValueError, TypeError, IndexError):
346
+ image_id = ""
347
+ return ImageInspectResult(
348
+ status=ImageInspectStatus.EXISTS,
349
+ image=image_name, message="",
350
+ image_id=image_id,
351
+ )
352
+
353
+ stderr_text = proc.stderr or ""
354
+ stderr_lower = stderr_text.lower()
355
+
356
+ # Permission denied patterns
357
+ if "permission denied" in stderr_lower:
358
+ return ImageInspectResult(
359
+ status=ImageInspectStatus.PERMISSION_DENIED,
360
+ image=image_name, message="Permission denied accessing Docker daemon",
361
+ )
362
+
363
+ # Explicit "not found" — Docker standard messages
364
+ # Must check BEFORE daemon keyword because Docker error response
365
+ # format "Error response from daemon: No such image: ..." contains
366
+ # the word "daemon" even for missing images
367
+ if any(kw in stderr_lower for kw in (
368
+ "no such image", "no such object",
369
+ "image not found", "not found",
370
+ )):
371
+ return ImageInspectResult(
372
+ status=ImageInspectStatus.MISSING,
373
+ image=image_name, message=f"Image '{image_name}' not found",
374
+ )
375
+
376
+ # Docker daemon / connection errors (AFTER not-found check)
377
+ if any(kw in stderr_lower for kw in (
378
+ "cannot connect", "is the docker daemon running",
379
+ "connection refused", "error during connect",
380
+ )):
381
+ return ImageInspectResult(
382
+ status=ImageInspectStatus.DOCKER_UNAVAILABLE,
383
+ image=image_name, message=f"Docker daemon unreachable: {stderr_text.strip()[:200]}",
384
+ )
385
+
386
+ # Fallback: unknown error
387
+ return ImageInspectResult(
388
+ status=ImageInspectStatus.ERROR,
389
+ image=image_name,
390
+ message=f"Image inspect failed (exit {proc.returncode}): {stderr_text.strip()[:200]}",
391
+ )
392
+
393
+ # ------------------------------------------------------------------
394
+ # run_captured
395
+ # ------------------------------------------------------------------
396
+
397
+ def run_captured(self, docker_argv: List[str],
398
+ *, timeout: Optional[float] = None,
399
+ input_text: Optional[str] = None) -> ProcessResult:
400
+ dp = self._resolve_path() or "docker"
401
+ try:
402
+ proc = subprocess.run(
403
+ [dp] + list(docker_argv),
404
+ capture_output=True, text=True,
405
+ timeout=timeout,
406
+ encoding="utf-8", errors="replace",
407
+ env=self._subprocess_env(),
408
+ input=input_text,
409
+ )
410
+ return ProcessResult(
411
+ stdout=proc.stdout or "",
412
+ stderr=proc.stderr or "",
413
+ exit_code=proc.returncode,
414
+ )
415
+ except FileNotFoundError:
416
+ return ProcessResult(
417
+ stdout="", stderr="command not found: docker",
418
+ exit_code=-1, command_not_found=True,
419
+ )
420
+ except subprocess.TimeoutExpired:
421
+ return ProcessResult(
422
+ stdout="", stderr="command timed out",
423
+ exit_code=-1, timed_out=True,
424
+ )
425
+ except OSError as exc:
426
+ return ProcessResult(
427
+ stdout="", stderr=f"command error: {exc}",
428
+ exit_code=-1, command_not_found=True,
429
+ )
430
+
431
+ # ------------------------------------------------------------------
432
+ # run_streaming
433
+ # ------------------------------------------------------------------
434
+
435
+ def run_streaming(self, docker_argv: List[str],
436
+ *, timeout: Optional[float] = None) -> ProcessResult:
437
+ dp = self._resolve_path() or "docker"
438
+ try:
439
+ proc = subprocess.run(
440
+ [dp] + list(docker_argv), timeout=timeout, env=self._subprocess_env(),
441
+ )
442
+ return ProcessResult(
443
+ stdout="", stderr="", exit_code=proc.returncode,
444
+ )
445
+ except FileNotFoundError:
446
+ return ProcessResult(
447
+ stdout="", stderr="command not found: docker",
448
+ exit_code=-1, command_not_found=True,
449
+ )
450
+ except subprocess.TimeoutExpired:
451
+ return ProcessResult(
452
+ stdout="", stderr="command timed out",
453
+ exit_code=-1, timed_out=True,
454
+ )
455
+ except OSError as exc:
456
+ return ProcessResult(
457
+ stdout="", stderr=f"command error: {exc}",
458
+ exit_code=-1, command_not_found=True,
459
+ )
460
+
461
+ # ------------------------------------------------------------------
462
+ # open_interactive (G-02 resize chain)
463
+ # ------------------------------------------------------------------
464
+
465
+ @staticmethod
466
+ def _client_from_env_safe():
467
+ """2.1.9 hotfix r3 (nairong #61, user VM repro): ``docker.from_env()``
468
+ with DOCKER_HOST unset loads the docker CLI context metadata, and
469
+ docker-py 7.x opens meta.json WITHOUT an encoding — on zh-CN Windows
470
+ that decodes as GBK while Docker Desktop (Chinese-language installs)
471
+ writes UTF-8 bytes into it, raising a bare UnicodeDecodeError wrapped
472
+ in a plain Exception ("corrupted meta file"). Not a DockerException,
473
+ so it escaped as a traceback → exit 1, zero output. Fall back to the
474
+ platform DEFAULT endpoint: an explicit base_url never reads
475
+ meta.json, skipping context resolution entirely."""
476
+ import docker # lazy: every other path stays dependency-free
477
+
478
+ try:
479
+ return docker.from_env()
480
+ except Exception: # noqa: BLE001 — context meta unreadable
481
+ if os.name == "nt":
482
+ return docker.DockerClient(base_url="npipe:////./pipe/docker_engine")
483
+ return docker.DockerClient(base_url="unix:///var/run/docker.sock")
484
+
485
+ def open_interactive(
486
+ self,
487
+ container: str,
488
+ argv: List[str],
489
+ env: Optional[Dict[str, str]] = None,
490
+ ) -> ProcessResult:
491
+ """Interactive TTY session via the Docker SDK (see protocol doc).
492
+
493
+ The sidecar is spawned with **pipes** (not a ConPTY) by the Rust
494
+ pty supervisor. stdin/stdout are raw byte streams - no console
495
+ processing, no codepage, no Unicode width tables. The container's
496
+ UTF-8 / VT sequences pass through directly to xterm.js.
497
+
498
+ Terminal size is passed via the ``AISC_RESIZE_FILE`` env var: Rust
499
+ writes ``"<cols> <rows>\\n"`` on resize, this sidecar polls it
500
+ (100ms) and calls ``exec_resize``. (G-02, 2026-08-10.)
501
+ """
502
+ import docker # lazy: every other path stays dependency-free
503
+ import requests # docker SDK dependency: transport-layer exceptions
504
+
505
+ # 2.1.9 hotfix (nairong #61): transport-layer failures (npipe
506
+ # ReadTimeout / ConnectionError) are NOT DockerException subclasses —
507
+ # they used to escape as a bare traceback with the sidecar's stderr
508
+ # discarded by the pty supervisor (Stdio::null), surfacing as
509
+ # "exit 1 with zero terminal output". Catch them here so every
510
+ # failure becomes an orderly ProcessResult whose message reaches
511
+ # the terminal. Same class of bug as #59 (resolver IncompleteRead).
512
+ def _transport_failure(stage: str, exc: Exception) -> ProcessResult:
513
+ return ProcessResult(
514
+ stdout="", stderr=f"{stage} failed: {exc}",
515
+ exit_code=-1, command_not_found=True,
516
+ )
517
+
518
+ try:
519
+ client = self._client_from_env_safe()
520
+ except (docker.errors.DockerException, requests.RequestException, OSError) as exc:
521
+ return _transport_failure("docker daemon unreachable", exc)
522
+ try:
523
+ exec_kwargs: Dict[str, Any] = {"tty": True, "stdin": True}
524
+ if env:
525
+ # v2.1.7 S6: session-scoped environment (the bash tutorial
526
+ # `help` function enters here) — never the image or a profile.
527
+ exec_kwargs["environment"] = dict(env)
528
+ exec_id = client.api.exec_create(container, list(argv), **exec_kwargs)["Id"]
529
+ except docker.errors.NotFound:
530
+ return ProcessResult(
531
+ stdout="", stderr="container not found",
532
+ exit_code=-1, command_not_found=True,
533
+ )
534
+ except (docker.errors.DockerException, requests.RequestException, OSError) as exc:
535
+ return _transport_failure("exec create", exc)
536
+ try:
537
+ sock = client.api.exec_start(exec_id, socket=True, tty=True)
538
+ except (docker.errors.DockerException, requests.RequestException, OSError) as exc:
539
+ return _transport_failure("exec start", exc)
540
+
541
+ stop = threading.Event()
542
+ errors: List[Exception] = []
543
+
544
+ # Initial resize from the resize file (set by Rust before spawn).
545
+ resize_file = os.environ.get("AISC_RESIZE_FILE")
546
+ last_size: Optional[tuple] = None
547
+ if resize_file:
548
+ try:
549
+ content = open(resize_file).read().strip().split()
550
+ if len(content) == 2:
551
+ last_size = (int(content[0]), int(content[1]))
552
+ client.api.exec_resize(
553
+ exec_id, height=last_size[1], width=last_size[0]
554
+ )
555
+ except Exception: # noqa: BLE001
556
+ pass
557
+
558
+ def read_sock(size: int) -> bytes:
559
+ """Read raw bytes from the docker-py exec socket.
560
+
561
+ Transport shape differs by platform: native sockets expose
562
+ ``recv``, while Unix-socket transports return ``socket.SocketIO``
563
+ which only exposes ``read`` (docker-py 7.x). Cover both so Linux
564
+ and WSL sessions receive PTY output.
565
+ """
566
+ if hasattr(sock, "recv"):
567
+ return sock.recv(size)
568
+ if hasattr(sock, "read"):
569
+ return sock.read(size)
570
+ return os.read(sock.fileno(), size)
571
+
572
+ def send_all(data: bytes) -> None:
573
+ """Send every byte; transports expose either ``sendall`` or ``write``."""
574
+ if hasattr(sock, "sendall"):
575
+ sock.sendall(data)
576
+ return
577
+ raw = getattr(sock, "_sock", None)
578
+ if raw is not None and hasattr(raw, "sendall"):
579
+ raw.sendall(data)
580
+ return
581
+ view = memoryview(data)
582
+ while view:
583
+ if hasattr(sock, "write") and getattr(sock, "writable", lambda: False)():
584
+ sent = sock.write(view)
585
+ else:
586
+ sent = os.write(sock.fileno(), view)
587
+ if sent is None:
588
+ raise OSError("socket write would block")
589
+ if sent <= 0:
590
+ raise OSError("socket write failed")
591
+ view = view[sent:]
592
+
593
+ def shutdown_write() -> None:
594
+ """Half-close the write side when stdin reaches EOF.
595
+
596
+ Raw sockets expose ``shutdown``; ``socket.SocketIO`` hides the raw
597
+ socket in ``_sock``. Close fully only as a last resort.
598
+ """
599
+ raw = getattr(sock, "_sock", None)
600
+ targets = [raw, sock] if raw is not None else [sock]
601
+ for target in targets:
602
+ if hasattr(target, "shutdown"):
603
+ try:
604
+ target.shutdown(socket.SHUT_WR)
605
+ return
606
+ except OSError:
607
+ pass
608
+ try:
609
+ sock.close()
610
+ except OSError:
611
+ pass
612
+
613
+ def drain() -> None:
614
+ """Socket -> stdout (raw bytes, no processing)."""
615
+ try:
616
+ while True:
617
+ chunk = read_sock(65536)
618
+ if not chunk:
619
+ break
620
+ os.write(sys.stdout.fileno(), chunk)
621
+ except Exception as exc: # noqa: BLE001
622
+ errors.append(exc)
623
+
624
+ def forward() -> None:
625
+ """stdin -> socket; on EOF, close the write side."""
626
+ try:
627
+ while True:
628
+ chunk = os.read(sys.stdin.fileno(), 4096)
629
+ if not chunk:
630
+ break
631
+ send_all(chunk)
632
+ except Exception as exc: # noqa: BLE001
633
+ errors.append(exc)
634
+ finally:
635
+ shutdown_write()
636
+
637
+ def watch_resize() -> None:
638
+ """Poll the resize file; forward size changes to exec_resize."""
639
+ if not resize_file:
640
+ return
641
+ # Local copy + module-level step helper: immune to the closure
642
+ # trap that previously dropped every post-initial resize (B-05).
643
+ last = last_size
644
+ while not stop.is_set():
645
+ last = _poll_resize_step(
646
+ resize_file,
647
+ last,
648
+ lambda size: client.api.exec_resize(
649
+ exec_id, height=size[1], width=size[0]
650
+ ),
651
+ )
652
+ stop.wait(0.1)
653
+
654
+ t_drain = threading.Thread(target=drain, daemon=True)
655
+ t_fwd = threading.Thread(target=forward, daemon=True)
656
+ t_resize = threading.Thread(target=watch_resize, daemon=True)
657
+ t_drain.start()
658
+ t_fwd.start()
659
+ t_resize.start()
660
+
661
+ exit_code = -1
662
+ # 2.1.9 hotfix (nairong #61): exec_inspect polls every 200ms over the
663
+ # same npipe; a single transient transport hiccup used to escape as a
664
+ # bare traceback (only APIError was caught), killing a HEALTHY session.
665
+ # Tolerate a few consecutive failures before giving up orderly.
666
+ inspect_failures = 0
667
+ try:
668
+ while True:
669
+ try:
670
+ info = client.api.exec_inspect(exec_id)
671
+ inspect_failures = 0
672
+ except (docker.errors.APIError, requests.RequestException, OSError) as exc:
673
+ inspect_failures += 1
674
+ if inspect_failures >= 3:
675
+ raise
676
+ time.sleep(0.5)
677
+ continue
678
+ if not info.get("Running"):
679
+ exit_code = int(info.get("ExitCode", 0))
680
+ break
681
+ time.sleep(0.2)
682
+ except (docker.errors.APIError, requests.RequestException, OSError) as exc:
683
+ errors.append(exc)
684
+ finally:
685
+ stop.set()
686
+ t_drain.join(timeout=5)
687
+ t_fwd.join(timeout=5)
688
+
689
+ if errors:
690
+ try:
691
+ os.write(2, ("[open_interactive] thread error: %r\n" % (errors[0],)).encode())
692
+ except OSError:
693
+ pass
694
+ return ProcessResult(
695
+ stdout="", stderr=f"exec stream error: {errors[0]}",
696
+ exit_code=-1,
697
+ )
698
+ return ProcessResult(stdout="", stderr="", exit_code=exit_code)
699
+
700
+ # ------------------------------------------------------------------
701
+ # run_non_interactive
702
+ # ------------------------------------------------------------------
703
+
704
+ def run_non_interactive(self, docker_argv: List[str],
705
+ *, timeout: Optional[float] = None) -> ProcessResult:
706
+ """Execute ``docker <argv>`` with DEVNULL stdin, inherited stdout/stderr."""
707
+ dp = self._resolve_path() or "docker"
708
+ try:
709
+ proc = subprocess.run(
710
+ [dp] + list(docker_argv),
711
+ stdin=subprocess.DEVNULL,
712
+ timeout=timeout,
713
+ env=self._subprocess_env(),
714
+ )
715
+ return ProcessResult(
716
+ stdout="", stderr="", exit_code=proc.returncode,
717
+ )
718
+ except FileNotFoundError:
719
+ return ProcessResult(
720
+ stdout="", stderr="command not found: docker",
721
+ exit_code=-1, command_not_found=True,
722
+ )
723
+ except subprocess.TimeoutExpired:
724
+ return ProcessResult(
725
+ stdout="", stderr="command timed out",
726
+ exit_code=-1, timed_out=True,
727
+ )
728
+ except OSError as exc:
729
+ return ProcessResult(
730
+ stdout="", stderr=f"command error: {exc}",
731
+ exit_code=-1, command_not_found=True,
732
+ )
733
+
734
+ # ------------------------------------------------------------------
735
+ # run_streaming_captured (build --events: real-time build.output)
736
+ # ------------------------------------------------------------------
737
+
738
+ def run_streaming_captured(self, docker_argv: List[str],
739
+ on_chunk: "Callable[[str, str], None]",
740
+ *, timeout: Optional[float] = None) -> ProcessResult:
741
+ """Run ``docker <argv>`` in its own process group, streaming each
742
+ stdout/stderr chunk to *on_chunk(stream, chunk)*. On any interruption
743
+ (cancel/error) the child's whole process tree is killed so Docker
744
+ build subprocesses do not outlive the CLI.
745
+
746
+ Drain strategy is platform-specific: ``select`` is POSIX-only
747
+ (Windows supports sockets only), so Windows uses reader threads +
748
+ a queue instead. Chunk ordering and the timeout contract
749
+ (applied at the final ``proc.wait``) are identical on both."""
750
+ dp = self._resolve_path() or "docker"
751
+ try:
752
+ proc = subprocess.Popen(
753
+ [dp] + list(docker_argv),
754
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
755
+ start_new_session=True, # own process group -> cancel can kill tree
756
+ env=self._subprocess_env(),
757
+ )
758
+ except FileNotFoundError:
759
+ return ProcessResult(
760
+ stdout="", stderr="command not found: docker",
761
+ exit_code=-1, command_not_found=True,
762
+ )
763
+ except OSError as exc:
764
+ return ProcessResult(
765
+ stdout="", stderr=f"command error: {exc}",
766
+ exit_code=-1, command_not_found=True,
767
+ )
768
+ try:
769
+ if os.name == "posix":
770
+ result = self._drain_select(proc, on_chunk, timeout=timeout)
771
+ else:
772
+ result = self._drain_threads(proc, on_chunk, timeout=timeout)
773
+ except BaseException:
774
+ # Cancel or error: kill the Docker child's whole process tree.
775
+ _kill_child(proc)
776
+ try:
777
+ proc.wait(timeout=5)
778
+ except Exception:
779
+ pass
780
+ raise
781
+ finally:
782
+ for stream in (proc.stdout, proc.stderr):
783
+ try:
784
+ stream.close()
785
+ except Exception:
786
+ pass
787
+ return result
788
+
789
+ def _drain_select(self, proc: subprocess.Popen,
790
+ on_chunk: "Callable[[str, str], None]",
791
+ *, timeout: Optional[float]) -> ProcessResult:
792
+ """POSIX: incremental read of both pipes via ``select`` (original
793
+ implementation, kept byte-for-byte equivalent)."""
794
+ streams = {proc.stdout: "stdout", proc.stderr: "stderr"}
795
+ open_fds = list(streams.keys())
796
+ while open_fds:
797
+ ready, _, _ = select.select(open_fds, [], [], 0.5)
798
+ for f in ready:
799
+ data = f.read1(4096)
800
+ if data:
801
+ on_chunk(streams[f], data.decode("utf-8", "replace"))
802
+ else:
803
+ open_fds.remove(f)
804
+ proc.wait(timeout=timeout)
805
+ return ProcessResult(stdout="", stderr="", exit_code=proc.returncode)
806
+
807
+ def _drain_threads(self, proc: subprocess.Popen,
808
+ on_chunk: "Callable[[str, str], None]",
809
+ *, timeout: Optional[float]) -> ProcessResult:
810
+ """Windows: two daemon reader threads feed a queue; the main thread
811
+ drains it and invokes *on_chunk* so emission stays single-threaded
812
+ (``JsonlEmitter`` is not lock-protected)."""
813
+ q: "queue.Queue[Optional[tuple[str, bytes]]]" = queue.Queue()
814
+
815
+ def _reader(stream: "object", name: str) -> None:
816
+ try:
817
+ while True:
818
+ chunk = stream.read1(4096)
819
+ if not chunk:
820
+ break
821
+ q.put((name, chunk))
822
+ finally:
823
+ q.put(None) # EOF sentinel for this stream
824
+
825
+ threads = [
826
+ threading.Thread(target=_reader, args=(proc.stdout, "stdout"), daemon=True),
827
+ threading.Thread(target=_reader, args=(proc.stderr, "stderr"), daemon=True),
828
+ ]
829
+ for t in threads:
830
+ t.start()
831
+
832
+ remaining = 2
833
+ while remaining:
834
+ try:
835
+ item = q.get(timeout=0.2)
836
+ except queue.Empty:
837
+ continue
838
+ if item is None:
839
+ remaining -= 1
840
+ continue
841
+ stream, chunk = item
842
+ on_chunk(stream, chunk.decode("utf-8", "replace"))
843
+
844
+ proc.wait(timeout=timeout)
845
+ return ProcessResult(stdout="", stderr="", exit_code=proc.returncode)
846
+
847
+ @property
848
+ def docker_path(self) -> str:
849
+ return self._resolve_path() or "docker"
850
+
851
+ # ------------------------------------------------------------------
852
+ # Container operations
853
+ # ------------------------------------------------------------------
854
+
855
+ def list_containers(self, all: bool = False) -> ProcessResult:
856
+ """List containers via ``docker ps`` (or ``docker ps -a`` if all=True)."""
857
+ argv = ["ps", "--format", "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}"]
858
+ if all:
859
+ argv.insert(1, "-a")
860
+ return self.run_captured(argv, timeout=10.0)
861
+
862
+ def stop_container(self, container_name: str, timeout: int = 10) -> ProcessResult:
863
+ """Stop a container via ``docker stop``."""
864
+ argv = ["stop", "-t", str(timeout), container_name]
865
+ return self.run_captured(argv, timeout=float(timeout + 5))
866
+
867
+ def remove_container(self, container_name: str, force: bool = False) -> ProcessResult:
868
+ """Remove a container via ``docker rm`` (with -f if force=True)."""
869
+ argv = ["rm"]
870
+ if force:
871
+ argv.append("-f")
872
+ argv.append(container_name)
873
+ return self.run_captured(argv, timeout=10.0)
874
+
875
+ def inspect_container(self, container_name: str) -> ProcessResult:
876
+ """Inspect a container via ``docker inspect``."""
877
+ argv = ["inspect", container_name]
878
+ return self.run_captured(argv, timeout=10.0)
879
+
880
+ # ------------------------------------------------------------------
881
+ # Image operations
882
+ # ------------------------------------------------------------------
883
+
884
+ def list_images(self) -> ProcessResult:
885
+ """List images via ``docker images``."""
886
+ argv = ["images", "--format", "{{.Repository}}:{{.Tag}}\t{{.ID}}\t{{.Size}}"]
887
+ return self.run_captured(argv, timeout=10.0)
888
+
889
+ def remove_image(self, image_name: str, force: bool = False) -> ProcessResult:
890
+ """Remove an image via ``docker rmi`` (with -f if force=True)."""
891
+ argv = ["rmi"]
892
+ if force:
893
+ argv.append("-f")
894
+ argv.append(image_name)
895
+ return self.run_captured(argv, timeout=30.0)
896
+
897
+ def pull_image(self, image_name: str) -> ProcessResult:
898
+ """Pull an image via ``docker pull``."""
899
+ argv = ["pull", image_name]
900
+ return self.run_captured(argv, timeout=300.0)
901
+
902
+ def tag_image(self, source: str, target: str) -> ProcessResult:
903
+ """Tag an image via ``docker tag``."""
904
+ argv = ["tag", source, target]
905
+ return self.run_captured(argv, timeout=10.0)
906
+
907
+
908
+ # ---------------------------------------------------------------------------
909
+ # Fake Docker executor (testing — zero process calls)
910
+ # ---------------------------------------------------------------------------
911
+
912
+ class FakeDockerExecutor:
913
+ """Fully controllable fake executor.
914
+
915
+ Configure responses via attributes / dicts. Tracks all calls so tests
916
+ can assert call counts and exact argv.
917
+ """
918
+
919
+ def __init__(self):
920
+ self._preflight_result: DockerPreflightResult = DockerPreflightResult(
921
+ docker_path="docker", available=True, reason="ok",
922
+ )
923
+ self._inspect_results: dict = {} # image_name → ImageInspectResult
924
+ self._default_inspect: ImageInspectResult = ImageInspectResult(
925
+ status=ImageInspectStatus.MISSING, image="",
926
+ )
927
+ self._captured_results: dict = {} # key substr → ProcessResult
928
+ self._default_captured: ProcessResult = ProcessResult(
929
+ stdout="", stderr="", exit_code=0,
930
+ )
931
+ self._streaming_exit_code: int = 0
932
+ self._streaming_chunks: List = [] # [(stream, chunk), ...] for run_streaming_captured
933
+
934
+ # Call tracking
935
+ self.calls: List[List[str]] = [] # run_captured argv
936
+ self.streaming_calls: List[List[str]] = [] # run_streaming argv
937
+ self.interactive_calls: List[tuple] = [] # (container, argv) for open_interactive
938
+ self.preflight_calls: int = 0
939
+ self.inspect_calls: List[str] = [] # image names inspected
940
+
941
+ # ------------------------------------------------------------------
942
+ # preflight
943
+ # ------------------------------------------------------------------
944
+
945
+ def preflight(self) -> DockerPreflightResult:
946
+ self.preflight_calls += 1
947
+ return self._preflight_result
948
+
949
+ def set_preflight(self, result: DockerPreflightResult) -> None:
950
+ self._preflight_result = result
951
+
952
+ # ------------------------------------------------------------------
953
+ # inspect_image
954
+ # ------------------------------------------------------------------
955
+
956
+ def inspect_image(self, image_name: str) -> ImageInspectResult:
957
+ self.inspect_calls.append(image_name)
958
+ return self._inspect_results.get(image_name, self._default_inspect)
959
+
960
+ def set_inspect(self, image_name: str, result: ImageInspectResult) -> None:
961
+ self._inspect_results[image_name] = result
962
+
963
+ def set_default_inspect(self, result: ImageInspectResult) -> None:
964
+ self._default_inspect = result
965
+
966
+ # ------------------------------------------------------------------
967
+ # run_captured
968
+ # ------------------------------------------------------------------
969
+
970
+ def run_captured(self, docker_argv: List[str],
971
+ *, timeout: Optional[float] = None) -> ProcessResult:
972
+ self.calls.append(list(docker_argv))
973
+ # Match by first subcommand or keyword
974
+ key = docker_argv[0] if docker_argv else ""
975
+ if key in self._captured_results:
976
+ return self._captured_results[key]
977
+ for k, v in self._captured_results.items():
978
+ if k in docker_argv:
979
+ return v
980
+ # fallback: scan all keys against joined arg string
981
+ joined = " ".join(docker_argv)
982
+ for k, v in self._captured_results.items():
983
+ if k in joined:
984
+ return v
985
+ return ProcessResult(
986
+ stdout=self._default_captured.stdout,
987
+ stderr=self._default_captured.stderr,
988
+ exit_code=self._default_captured.exit_code,
989
+ )
990
+
991
+ def set_captured(self, key: str, result: ProcessResult) -> None:
992
+ self._captured_results[key] = result
993
+
994
+ def set_default_captured(self, result: ProcessResult) -> None:
995
+ self._default_captured = result
996
+
997
+ # ------------------------------------------------------------------
998
+ # run_streaming
999
+ # ------------------------------------------------------------------
1000
+
1001
+ def run_streaming(self, docker_argv: List[str],
1002
+ *, timeout: Optional[float] = None) -> ProcessResult:
1003
+ self.streaming_calls.append(list(docker_argv))
1004
+ return ProcessResult(
1005
+ stdout="", stderr="",
1006
+ exit_code=self._streaming_exit_code if self._streaming_exit_code >= 0 else -1,
1007
+ command_not_found=(self._streaming_exit_code < 0),
1008
+ )
1009
+
1010
+ # ------------------------------------------------------------------
1011
+ # open_interactive (G-02 resize chain)
1012
+ # ------------------------------------------------------------------
1013
+
1014
+ def set_streaming_exit(self, code: int) -> None:
1015
+ """Configure the exit code returned by run_streaming / open_interactive."""
1016
+ self._streaming_exit_code = code
1017
+
1018
+ def run_streaming_captured(self, docker_argv: List[str],
1019
+ on_chunk: "Callable[[str, str], None]",
1020
+ *, timeout: Optional[float] = None) -> ProcessResult:
1021
+ """Replay configured chunks to *on_chunk*, then return the preset exit
1022
+ code. Set chunks via :meth:`set_streaming_chunks`."""
1023
+ self.streaming_calls.append(list(docker_argv))
1024
+ for stream, chunk in self._streaming_chunks:
1025
+ on_chunk(stream, chunk)
1026
+ return ProcessResult(
1027
+ stdout="", stderr="",
1028
+ exit_code=self._streaming_exit_code if self._streaming_exit_code >= 0 else -1,
1029
+ command_not_found=(self._streaming_exit_code < 0),
1030
+ )
1031
+
1032
+ def set_streaming_chunks(self, chunks) -> None:
1033
+ """Configure ``[(stream, chunk), ...]`` replayed by run_streaming_captured."""
1034
+ self._streaming_chunks = list(chunks)
1035
+
1036
+ def open_interactive(
1037
+ self,
1038
+ container: str,
1039
+ argv: List[str],
1040
+ env: Optional[Dict[str, str]] = None,
1041
+ ) -> ProcessResult:
1042
+ """Fake interactive session: record (container, argv, env), return the
1043
+ configured streaming exit code."""
1044
+ self.interactive_calls.append((container, list(argv), dict(env or {})))
1045
+ return ProcessResult(
1046
+ stdout="", stderr="",
1047
+ exit_code=self._streaming_exit_code if self._streaming_exit_code >= 0 else -1,
1048
+ command_not_found=(self._streaming_exit_code < 0),
1049
+ )
1050
+
1051
+ # ------------------------------------------------------------------
1052
+ # Zero-call assertion
1053
+ # ------------------------------------------------------------------
1054
+
1055
+ @property
1056
+ def total_calls(self) -> int:
1057
+ return len(self.calls) + len(self.streaming_calls) + len(self.interactive_calls)
1058
+
1059
+ def assert_zero_docker_calls(self, msg: str = "") -> None:
1060
+ """Fail if any docker subprocess call was made."""
1061
+ if self.total_calls > 0:
1062
+ raise AssertionError(
1063
+ f"Expected zero docker calls, got {self.total_calls}. {msg}"
1064
+ )
1065
+
1066
+ # ------------------------------------------------------------------
1067
+ # Container operations (fake implementations)
1068
+ # ------------------------------------------------------------------
1069
+
1070
+ def list_containers(self, all: bool = False) -> ProcessResult:
1071
+ """Fake container list."""
1072
+ self.calls.append(("list_containers", {"all": all}))
1073
+ return self._default_captured or ProcessResult(
1074
+ stdout="", stderr="", exit_code=0
1075
+ )
1076
+
1077
+ def stop_container(self, container_name: str, timeout: int = 10) -> ProcessResult:
1078
+ """Fake container stop."""
1079
+ self.calls.append(("stop_container", {"name": container_name, "timeout": timeout}))
1080
+ return self._default_captured or ProcessResult(
1081
+ stdout="", stderr="", exit_code=0
1082
+ )
1083
+
1084
+ def remove_container(self, container_name: str, force: bool = False) -> ProcessResult:
1085
+ """Fake container removal."""
1086
+ self.calls.append(("remove_container", {"name": container_name, "force": force}))
1087
+ return self._default_captured or ProcessResult(
1088
+ stdout="", stderr="", exit_code=0
1089
+ )
1090
+
1091
+ def inspect_container(self, container_name: str) -> ProcessResult:
1092
+ """Fake container inspect."""
1093
+ self.calls.append(("inspect_container", {"name": container_name}))
1094
+ return self._default_captured or ProcessResult(
1095
+ stdout="[]", stderr="", exit_code=0
1096
+ )
1097
+
1098
+ # ------------------------------------------------------------------
1099
+ # Image operations (fake implementations)
1100
+ # ------------------------------------------------------------------
1101
+
1102
+ def list_images(self) -> ProcessResult:
1103
+ """Fake image list."""
1104
+ self.calls.append(("list_images", {}))
1105
+ return self._default_captured or ProcessResult(
1106
+ stdout="", stderr="", exit_code=0
1107
+ )
1108
+
1109
+ def remove_image(self, image_name: str, force: bool = False) -> ProcessResult:
1110
+ """Fake image removal."""
1111
+ self.calls.append(("remove_image", {"name": image_name, "force": force}))
1112
+ return self._default_captured or ProcessResult(
1113
+ stdout="", stderr="", exit_code=0
1114
+ )
1115
+
1116
+ def pull_image(self, image_name: str) -> ProcessResult:
1117
+ """Fake image pull."""
1118
+ self.calls.append(("pull_image", {"name": image_name}))
1119
+ return self._default_captured or ProcessResult(
1120
+ stdout="", stderr="", exit_code=0
1121
+ )
1122
+
1123
+ def tag_image(self, source: str, target: str) -> ProcessResult:
1124
+ """Fake image tag."""
1125
+ self.calls.append(("tag_image", {"source": source, "target": target}))
1126
+ return self._default_captured or ProcessResult(
1127
+ stdout="", stderr="", exit_code=0
1128
+ )
1129
+
1130
+
1131
+ # ---------------------------------------------------------------------------
1132
+ # Resource validation helpers (no subprocess)
1133
+ # ---------------------------------------------------------------------------
1134
+
1135
+ def validate_build_resources(root: Path) -> None:
1136
+ """Validate that required build resources exist.
1137
+
1138
+ Raises FileNotFoundError with descriptive message.
1139
+ """
1140
+ dockerfile = root / "container" / "Dockerfile"
1141
+ if not dockerfile.is_file():
1142
+ raise FileNotFoundError(f"Dockerfile not found: {dockerfile}")
1143
+ versions_env = root / "config" / "versions.env"
1144
+ if not versions_env.is_file():
1145
+ raise FileNotFoundError(f"versions.env not found: {versions_env}")
1146
+
1147
+
1148
+ def validate_run_resources(workspace: Path) -> None:
1149
+ """Validate that *workspace* exists and is readable.
1150
+
1151
+ Raises FileNotFoundError, PermissionError, or NotADirectoryError.
1152
+ """
1153
+ if not workspace.exists():
1154
+ raise FileNotFoundError(f"工作区目录不存在: {workspace}")
1155
+ if not workspace.is_dir():
1156
+ raise NotADirectoryError(f"Workspace is not a directory: {workspace}")
1157
+ if not os.access(str(workspace), os.R_OK):
1158
+ raise PermissionError(f"Workspace is not readable: {workspace}")
1159
+
1160
+
1161
+ def validate_proxy_config(proxy_config: Path) -> None:
1162
+ """Validate that proxy config exists and is readable."""
1163
+ if not proxy_config.is_file():
1164
+ raise FileNotFoundError(
1165
+ f"Proxy network mode requires {proxy_config}. "
1166
+ "Please create the proxy configuration first."
1167
+ )
1168
+ if not os.access(str(proxy_config), os.R_OK):
1169
+ raise PermissionError(f"Proxy config is not readable: {proxy_config}")
1170
+
1171
+
1172
+ # ---------------------------------------------------------------------------
1173
+ # Cross-platform argv formatter for dry-run display
1174
+ # ---------------------------------------------------------------------------
1175
+
1176
+ def format_argv_display(argv: List[str]) -> str:
1177
+ """Format an argv list for human-readable display.
1178
+
1179
+ Uses ``shlex.join`` on POSIX, ``subprocess.list2cmdline`` on Windows.
1180
+ """
1181
+ if sys.platform == "win32":
1182
+ return subprocess.list2cmdline(argv)
1183
+ return shlex.join(argv)