diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml index 522f3c0..335ef89 100644 --- a/.github/workflows/publish-image.yml +++ b/.github/workflows/publish-image.yml @@ -13,7 +13,7 @@ on: env: REGISTRY: ghcr.io - IMAGE_NAME: boxlite-labs/claudebox-runtime + IMAGE_NAME: boxlite-ai/claudebox-runtime jobs: build-and-push: diff --git a/Makefile b/Makefile index f8c10b7..d588640 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: build-image publish-image install test lint format clean help # Configuration -IMAGE := ghcr.io/boxlite-labs/claudebox-runtime +IMAGE := ghcr.io/boxlite-ai/claudebox-runtime VERSION := $(shell grep 'version = ' pyproject.toml | head -1 | cut -d'"' -f2) help: diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 57cd667..2fe20d1 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -88,7 +88,7 @@ If you prefer containerized development: ```bash # Pull the ClaudeBox runtime image -docker pull ghcr.io/boxlite-labs/claudebox-runtime:latest +docker pull ghcr.io/boxlite-ai/claudebox-runtime:latest # Use with ClaudeBox Python API python3 -c "from claudebox import ClaudeBox; print('Ready!')" diff --git a/docs/guides/templates.md b/docs/guides/templates.md index 4a674dc..4dc516a 100644 --- a/docs/guides/templates.md +++ b/docs/guides/templates.md @@ -47,7 +47,7 @@ ClaudeBox provides **6 built-in templates**: **Docker Image:** ``` -ghcr.io/boxlite-labs/claudebox-runtime:latest +ghcr.io/boxlite-ai/claudebox-runtime:latest ``` **When to use:** @@ -79,7 +79,7 @@ async def default_example(): **Docker Image:** ``` -ghcr.io/boxlite-labs/claudebox-runtime:web-dev +ghcr.io/boxlite-ai/claudebox-runtime:web-dev ``` **When to use:** @@ -130,7 +130,7 @@ await box.code("Create a full-stack app: React frontend + Express backend") **Docker Image:** ``` -ghcr.io/boxlite-labs/claudebox-runtime:data-science +ghcr.io/boxlite-ai/claudebox-runtime:data-science ``` **When to use:** @@ -191,7 +191,7 @@ await box.code("Create Jupyter notebook for exploratory analysis") **Docker Image:** ``` -ghcr.io/boxlite-labs/claudebox-runtime:security +ghcr.io/boxlite-ai/claudebox-runtime:security ``` **When to use:** @@ -246,7 +246,7 @@ await box.code("Decode base64-encoded flag from file") **Docker Image:** ``` -ghcr.io/boxlite-labs/claudebox-runtime:devops +ghcr.io/boxlite-ai/claudebox-runtime:devops ``` **When to use:** @@ -300,7 +300,7 @@ await box.code("Create Ansible playbook to configure web servers") **Docker Image:** ``` -ghcr.io/boxlite-labs/claudebox-runtime:mobile +ghcr.io/boxlite-ai/claudebox-runtime:mobile ``` **When to use:** @@ -438,7 +438,7 @@ async def main(): **1. Create Dockerfile:** ```dockerfile # Start from ClaudeBox base -FROM ghcr.io/boxlite-labs/claudebox-runtime:latest +FROM ghcr.io/boxlite-ai/claudebox-runtime:latest # Install custom tools RUN apt-get update && apt-get install -y \ @@ -479,7 +479,7 @@ async def main(): ### Example: Custom ML Research Template ```dockerfile -FROM ghcr.io/boxlite-labs/claudebox-runtime:data-science +FROM ghcr.io/boxlite-ai/claudebox-runtime:data-science # Additional ML frameworks RUN pip3 install \ @@ -664,13 +664,13 @@ for name, description in templates.items(): **Error:** ``` -Error pulling image 'ghcr.io/boxlite-labs/claudebox-runtime:web-dev' +Error pulling image 'ghcr.io/boxlite-ai/claudebox-runtime:web-dev' ``` **Solution:** ```bash # Pull image manually -docker pull ghcr.io/boxlite-labs/claudebox-runtime:web-dev +docker pull ghcr.io/boxlite-ai/claudebox-runtime:web-dev # Verify image exists docker images | grep claudebox diff --git a/examples/01_basic_usage.py b/examples/01_basic_usage.py index 828d089..f0de550 100644 --- a/examples/01_basic_usage.py +++ b/examples/01_basic_usage.py @@ -9,6 +9,7 @@ """ import asyncio +import logging from claudebox import ClaudeBox @@ -171,4 +172,5 @@ async def main(): if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG, format="%(name)s %(levelname)s %(message)s") asyncio.run(main()) diff --git a/examples/02_skills.py b/examples/02_skills.py index 4b6288c..851bdb8 100644 --- a/examples/02_skills.py +++ b/examples/02_skills.py @@ -9,6 +9,7 @@ """ import asyncio +import logging from claudebox import ( API_SKILL, @@ -293,4 +294,5 @@ async def main(): if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(name)s %(levelname)s %(message)s") asyncio.run(main()) diff --git a/examples/03_templates.py b/examples/03_templates.py index d8af0f9..eae38a1 100644 --- a/examples/03_templates.py +++ b/examples/03_templates.py @@ -8,6 +8,7 @@ """ import asyncio +import logging from claudebox import ( ClaudeBox, @@ -144,7 +145,7 @@ async def example_template_string(): # You can use template string directly async with ClaudeBox( session_id="string-template", - template="ghcr.io/boxlite-labs/claudebox-runtime:latest", + template="ghcr.io/boxlite-ai/claudebox-runtime:latest", ) as box: print(f"Using template string directly") print(f" Box ID: {box.id}") @@ -157,7 +158,7 @@ async def example_custom_image(): print("\n=== Example 9: Custom Docker Image ===") # Use a completely custom image - custom_image = "ghcr.io/boxlite-labs/claudebox-runtime:latest" + custom_image = "ghcr.io/boxlite-ai/claudebox-runtime:latest" async with ClaudeBox( session_id="custom-image", image=custom_image @@ -177,7 +178,7 @@ async def example_image_overrides_template(): async with ClaudeBox( session_id="override-example", template=SandboxTemplate.WEB_DEV, # This is ignored - image="ghcr.io/boxlite-labs/claudebox-runtime:latest", # This is used + image="ghcr.io/boxlite-ai/claudebox-runtime:latest", # This is used ) as box: print(f"Image parameter takes priority over template") print(f" Box ID: {box.id}") @@ -207,7 +208,7 @@ async def example_get_template_image(): print(f" {web_dev_image}") # Works with string too - ds_image = get_template_image("ghcr.io/boxlite-labs/claudebox-runtime:data-science") + ds_image = get_template_image("ghcr.io/boxlite-ai/claudebox-runtime:data-science") print(f"\nData Science image URL:") print(f" {ds_image}") @@ -250,4 +251,5 @@ async def main(): if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(name)s %(levelname)s %(message)s") asyncio.run(main()) diff --git a/examples/04_rl_rewards.py b/examples/04_rl_rewards.py index 22c75c3..bb39010 100644 --- a/examples/04_rl_rewards.py +++ b/examples/04_rl_rewards.py @@ -9,6 +9,7 @@ """ import asyncio +import logging from claudebox import ( BuiltinRewards, @@ -393,4 +394,5 @@ async def main(): if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(name)s %(levelname)s %(message)s") asyncio.run(main()) diff --git a/examples/05_security.py b/examples/05_security.py index b0d3bd7..46199c1 100644 --- a/examples/05_security.py +++ b/examples/05_security.py @@ -9,6 +9,7 @@ """ import asyncio +import logging from claudebox import ( READONLY_POLICY, @@ -393,4 +394,5 @@ async def main(): if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(name)s %(levelname)s %(message)s") asyncio.run(main()) diff --git a/examples/06_advanced.py b/examples/06_advanced.py index 5d71eed..d7889f1 100644 --- a/examples/06_advanced.py +++ b/examples/06_advanced.py @@ -9,6 +9,7 @@ """ import asyncio +import logging from claudebox import ( API_SKILL, @@ -431,4 +432,5 @@ async def main(): if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(name)s %(levelname)s %(message)s") asyncio.run(main()) diff --git a/examples/README.md b/examples/README.md index bf5dc4b..273083b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -295,7 +295,7 @@ Complex workflows and production patterns: 8. **Template as String** ```python async with ClaudeBox( - template="ghcr.io/boxlite-labs/claudebox-runtime:latest" + template="ghcr.io/boxlite-ai/claudebox-runtime:latest" ) as box: ... ``` @@ -303,7 +303,7 @@ Complex workflows and production patterns: 9. **Custom Docker Images** ```python async with ClaudeBox( - image="ghcr.io/boxlite-labs/claudebox-runtime:custom" + image="ghcr.io/boxlite-ai/claudebox-runtime:custom" ) as box: ... ``` diff --git a/examples/computer_use.py b/examples/computer_use.py index df268e3..0af7b65 100644 --- a/examples/computer_use.py +++ b/examples/computer_use.py @@ -8,6 +8,7 @@ """ import asyncio +import logging from claudebox import ClaudeBox @@ -28,4 +29,5 @@ async def main(): if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(name)s %(levelname)s %(message)s") asyncio.run(main()) diff --git a/examples/persistent_session.py b/examples/persistent_session.py index 267038f..bf04549 100644 --- a/examples/persistent_session.py +++ b/examples/persistent_session.py @@ -6,6 +6,7 @@ """ import asyncio +import logging from claudebox import ClaudeBox @@ -58,4 +59,5 @@ async def main(): if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(name)s %(levelname)s %(message)s") asyncio.run(main()) diff --git a/image/build.sh b/image/build.sh index 94c5f23..1427ad7 100755 --- a/image/build.sh +++ b/image/build.sh @@ -2,7 +2,7 @@ set -e VERSION=${1:-latest} -IMAGE="ghcr.io/boxlite-labs/claudebox-runtime" +IMAGE="ghcr.io/boxlite-ai/claudebox-runtime" echo "Building $IMAGE:$VERSION..." docker build -t "$IMAGE:$VERSION" . diff --git a/pyproject.toml b/pyproject.toml index 0ace862..3673487 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ classifiers = [ "Topic :: Scientific/Engineering :: Artificial Intelligence", ] dependencies = [ - "boxlite>=0.4.4", + "boxlite>=0.5.9", ] [project.optional-dependencies] diff --git a/src/claudebox/box.py b/src/claudebox/box.py index 0b6cc16..0d5c8a4 100644 --- a/src/claudebox/box.py +++ b/src/claudebox/box.py @@ -2,17 +2,22 @@ from __future__ import annotations +import asyncio +import json +import logging import os import uuid -from collections.abc import Callable -from typing import TYPE_CHECKING +from collections.abc import AsyncGenerator, Callable +from typing import TYPE_CHECKING, Any from claudebox.results import CodeResult, SessionMetadata from claudebox.session import SessionManager from claudebox.workspace import SessionInfo, WorkspaceManager if TYPE_CHECKING: - from boxlite import Boxlite + from boxlite import Box, Boxlite, Execution + +logger = logging.getLogger("claudebox") class ClaudeBox: @@ -25,7 +30,7 @@ class ClaudeBox: ... print(result.response) """ - DEFAULT_IMAGE = "ghcr.io/boxlite-labs/claudebox-runtime:latest" + DEFAULT_IMAGE = "ghcr.io/boxlite-ai/claudebox-runtime:latest" def __init__( self, @@ -64,9 +69,7 @@ def __init__( # Check if session already exists (reconnecting) if self._workspace_manager.session_exists(session_id): # Reconnecting to existing session - self._session_workspace = self._workspace_manager.get_session_workspace( - session_id - ) + self._session_workspace = self._workspace_manager.get_session_workspace(session_id) else: # Creating new persistent session self._session_workspace = self._workspace_manager.create_session_workspace( @@ -114,14 +117,10 @@ def __init__( volumes_list = list(volumes or []) # Mount workspace directory - volumes_list.append( - (self._session_workspace.workspace_dir, "/config/workspace") - ) + volumes_list.append((self._session_workspace.workspace_dir, "/config/workspace")) # Mount metadata directory - volumes_list.append( - (self._session_workspace.metadata_dir, "/config/.claudebox") - ) + volumes_list.append((self._session_workspace.metadata_dir, "/config/.claudebox")) self._runtime = runtime or Boxlite.default() @@ -134,27 +133,44 @@ def __init__( if not final_image: final_image = self.DEFAULT_IMAGE - # Note: Not using named boxes for now due to BoxLite naming issues - # Boxes are tracked by workspace path instead - self._box = self._runtime.create( - BoxOptions( - image=final_image, - cpus=cpus, - memory_mib=memory_mib, - disk_size_gb=disk_size_gb, - env=env_list, - volumes=volumes_list, - ports=ports or [], - auto_remove=auto_remove, - ), + # Store BoxOptions for deferred creation in __aenter__ + # (BoxLite's runtime.create() is async, so we can't call it in __init__) + self._box_options = BoxOptions( + image=final_image, + cpus=cpus, + memory_mib=memory_mib, + disk_size_gb=disk_size_gb, + env=env_list, + volumes=volumes_list, + ports=ports or [], + auto_remove=auto_remove, ) + self._box: Box | None = None # Created in __aenter__ + + # Save env for exec() calls (auth + skill vars) + self._claude_env = env_list or None + + # Claude CLI persistent process state (for stream()) + self._execution: Execution | None = None + self._stdin: Any = None + self._stdout: Any = None + self._stderr: Any = None + self._stderr_lines: list[str] = [] + self._stderr_task: asyncio.Task | None = None + self._claude_session_id = "default" + self._buffer = "" # Store session metadata (will be updated on first code() call) self._session_metadata: SessionMetadata | None = None async def __aenter__(self) -> ClaudeBox: + # Create box now (deferred from __init__ since runtime.create() is async) + self._box = await self._runtime.create(self._box_options) await self._box.__aenter__() + # Setup non-root user (Claude CLI rejects --dangerously-skip-permissions as root) + await self._setup_claude_user() + # Create or load session metadata if self._session_manager.session_exists(): self._session_metadata = self._session_manager.load_session() @@ -171,20 +187,34 @@ async def __aenter__(self) -> ClaudeBox: return self async def __aexit__(self, exc_type, exc_val, exc_tb): + # Close Claude CLI persistent process if running + if self._stdin: + await self._stdin.close() + if self._execution: + await self._execution.wait() + if self._stderr_task and not self._stderr_task.done(): + self._stderr_task.cancel() + self._execution = None + self._stdin = None + self._stdout = None + # Update session metadata before exit if self._session_metadata: self._session_manager.update_session(self._session_metadata) # Clean up ephemeral workspace if not self._is_persistent: - self._workspace_manager.cleanup_session( - self._session_id, remove_workspace=True - ) + self._workspace_manager.cleanup_session(self._session_id, remove_workspace=True) return await self._box.__aexit__(exc_type, exc_val, exc_tb) @property def id(self) -> str: + if self._box is None: + raise RuntimeError( + "ClaudeBox not started. Use 'async with ClaudeBox() as box:' " + "or call 'await box.__aenter__()' first." + ) return self._box.id @property @@ -202,6 +232,44 @@ def is_persistent(self) -> bool: """Check if this is a persistent session.""" return self._is_persistent + async def _setup_claude_user(self) -> None: + """Create non-root user for Claude CLI. + + Claude CLI rejects --dangerously-skip-permissions when running as root. + We create a 'claude' user and run all CLI invocations via 'su -c'. + """ + assert self._box is not None + setup_script = ( + "id -u claude >/dev/null 2>&1 || useradd -m -s /bin/bash claude; " + "mkdir -p /home/claude; " + "chown -R claude:claude /home/claude; " + "chmod -R 777 /config/workspace" + ) + execution = await self._box.exec("sh", ["-c", setup_script], None) + try: + async for _ in execution.stdout(): + pass + except Exception: + pass + result = await execution.wait() + if result.exit_code == 0: + logger.debug("Non-root user 'claude' ready") + else: + logger.warning("Failed to setup non-root user (exit %d)", result.exit_code) + + def _build_claude_cmd(self, claude_args: list[str]) -> tuple[str, list[str]]: + """Build su -c command to run Claude CLI as non-root user.""" + import shlex + + parts = [] + if self._claude_env: + for key, value in self._claude_env: + parts.append(f"{key}={shlex.quote(value)}") + parts.append("claude") + parts.extend(claude_args) + cmd_str = " ".join(parts) + return "su", ["-c", cmd_str, "claude"] + async def code( self, prompt: str, @@ -209,22 +277,148 @@ async def code( allowed_tools: list[str] | None = None, disallowed_tools: list[str] | None = None, ) -> CodeResult: - """Run Claude Code CLI with a prompt.""" - args = ["-p", prompt, "--output-format", "json"] + """Run Claude Code CLI with a prompt. + + Uses interactive stream-json mode (stdin/stdout) to send the prompt + and receive streamed NDJSON responses. Runs as non-root user via su. + """ + assert self._box is not None + claude_args = [ + "--input-format", + "stream-json", + "--output-format", + "stream-json", + "--dangerously-skip-permissions", + "--verbose", + ] if max_turns is not None: - args.extend(["--max-turns", str(max_turns)]) + claude_args.extend(["--max-turns", str(max_turns)]) if allowed_tools: for tool in allowed_tools: - args.extend(["--allowedTools", tool]) + claude_args.extend(["--allowedTools", tool]) if disallowed_tools: for tool in disallowed_tools: - args.extend(["--disallowedTools", tool]) - - # Execute claude command directly - # Note: script command for TTY emulation has different syntax on macOS vs Linux - cmd = "claude " + " ".join(f'"{arg}"' for arg in args) - result = await self._exec("/bin/sh", "-c", cmd) - code_result = CodeResult.from_exec(result.exit_code, result.stdout, result.stderr) + claude_args.extend(["--disallowedTools", tool]) + + mcp_config_path = getattr(self, "_mcp_config_path", None) + if mcp_config_path: + claude_args.extend(["--mcp-config", mcp_config_path]) + + cmd, cmd_args = self._build_claude_cmd(claude_args) + logger.debug("Starting Claude CLI: %s %s", cmd, cmd_args[:2]) + + execution = await self._box.exec(cmd, cmd_args, None) + stdin = execution.stdin() + stdout = execution.stdout() + stderr = execution.stderr() + + # Drain stderr in background + stderr_lines: list[str] = [] + + async def _drain_stderr(): + if stderr: + try: + async for chunk in stderr: + text = ( + chunk.decode("utf-8", errors="replace") + if isinstance(chunk, bytes) + else chunk + ) + stderr_lines.append(text) + logger.debug("[stderr] %s", text.strip()) + except Exception: + pass + + stderr_task = asyncio.create_task(_drain_stderr()) + + # Send prompt as NDJSON on stdin (interactive mode) + msg = { + "type": "user", + "message": {"role": "user", "content": prompt}, + "session_id": "default", + "parent_tool_use_id": None, + } + payload = json.dumps(msg) + "\n" + logger.debug("Sending prompt (%d bytes)", len(payload)) + await stdin.send_input(payload.encode()) + + # Read NDJSON stream, log each message, collect result + buffer = "" + result_data = None + response_text = "" + + while True: + try: + chunk = await asyncio.wait_for(stdout.__anext__(), timeout=120) + except StopAsyncIteration: + break + except asyncio.TimeoutError: + logger.warning("Claude CLI timed out after 120s") + break + + chunk_str = ( + chunk.decode("utf-8", errors="replace") if isinstance(chunk, bytes) else chunk + ) + buffer += chunk_str + + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + line = line.strip() + if not line: + continue + try: + parsed = json.loads(line) + except json.JSONDecodeError: + continue + + msg_type = parsed.get("type") + + if msg_type == "assistant": + for block in parsed.get("message", {}).get("content", []): + if block.get("type") == "text": + logger.info("[claude] %s", block["text"]) + elif block.get("type") == "tool_use": + logger.info("[tool_use] %s", block.get("name")) + + elif msg_type == "user": + for block in parsed.get("message", {}).get("content", []): + if block.get("type") == "tool_result" and block.get("is_error"): + logger.warning("[tool_error] %s", str(block.get("content", ""))[:200]) + + elif msg_type == "result": + result_data = parsed + response_text = parsed.get("result", "") + cost = parsed.get("total_cost_usd", 0) + duration = parsed.get("duration_ms", 0) + logger.info("[done] cost=$%.4f duration=%.1fs", cost, duration / 1000) + break + + if result_data is not None: + break + + # Close stdin and wait for process + await stdin.close() + exec_result = await execution.wait() + stderr_task.cancel() + + # Build CodeResult from stream data + is_error = (result_data or {}).get("is_error", False) + success = exec_result.exit_code == 0 and not is_error + error = None + if is_error: + error = response_text or "Unknown error" + elif exec_result.exit_code != 0: + error = "".join(stderr_lines) or f"Exit code {exec_result.exit_code}" + if not response_text: + response_text = error + + code_result = CodeResult( + success=success, + response=response_text, + exit_code=exec_result.exit_code, + raw_output=response_text, + error=error, + ) # Calculate reward if reward function provided if self._reward_fn: @@ -232,36 +426,133 @@ async def code( return code_result - async def _exec(self, cmd: str, *args: str): - """Execute command in box.""" - execution = await self._box.exec(cmd, list(args) if args else None, None) + async def stream( + self, + prompt: str, + ) -> AsyncGenerator[dict, None]: + """Stream Claude Code responses using a persistent process. + + Uses the stream-json NDJSON protocol for real-time streaming + and multi-turn context preservation. The Claude CLI process + is started on first call and reused for subsequent messages. - stdout_lines = [] - if stdout := execution.stdout(): - async for line in stdout: - stdout_lines.append( - line.decode("utf-8", errors="replace") if isinstance(line, bytes) else line - ) + Args: + prompt: The message to send to Claude. - stderr_lines = [] - if stderr := execution.stderr(): - async for line in stderr: - stderr_lines.append( - line.decode("utf-8", errors="replace") if isinstance(line, bytes) else line + Yields: + Parsed JSON messages as they arrive from Claude CLI. + + Example: + >>> async with ClaudeBox() as box: + ... async for msg in box.stream("Create hello.py"): + ... if msg.get("type") == "assistant": + ... print(msg) + """ + if not self._execution: + await self._start_claude() + async for msg in self._send_message(prompt): + yield msg + + async def _start_claude(self) -> None: + """Start Claude CLI in stream-json mode for multi-turn streaming.""" + assert self._box is not None + claude_args = [ + "--input-format", + "stream-json", + "--output-format", + "stream-json", + "--dangerously-skip-permissions", + "--verbose", + ] + mcp_config_path = getattr(self, "_mcp_config_path", None) + if mcp_config_path: + claude_args.extend(["--mcp-config", mcp_config_path]) + + cmd, cmd_args = self._build_claude_cmd(claude_args) + logger.debug("Starting Claude CLI (persistent): %s", cmd) + + self._execution = await self._box.exec(cmd, cmd_args, None) + self._stdin = self._execution.stdin() + self._stdout = self._execution.stdout() + self._stderr = self._execution.stderr() + self._buffer = "" + self._stderr_lines = [] + + # Drain stderr in background so errors are captured + if self._stderr: + self._stderr_task = asyncio.create_task(self._drain_stderr()) + + async def _drain_stderr(self) -> None: + """Collect stderr output in the background.""" + assert self._stderr is not None + try: + async for chunk in self._stderr: + text = ( + chunk.decode("utf-8", errors="replace") if isinstance(chunk, bytes) else chunk ) + self._stderr_lines.append(text) + except Exception: + pass - exec_result = await execution.wait() + async def _send_message(self, content: str) -> AsyncGenerator[dict, None]: + """Send a message via NDJSON and yield streamed responses. - class Result: - def __init__(self, exit_code, stdout, stderr): - self.exit_code = exit_code - self.stdout = stdout - self.stderr = stderr + BoxLite streams stdout in fixed-size chunks (not line-buffered), + so we buffer data and parse complete JSON lines delimited by newlines. + Uses explicit __anext__() with timeout matching the boxlite example. + """ + if not self._stdin or not self._stdout: + raise RuntimeError("Claude CLI not started") + + msg = { + "type": "user", + "message": {"role": "user", "content": content}, + "session_id": self._claude_session_id, + "parent_tool_use_id": None, + } + payload = json.dumps(msg) + "\n" + await self._stdin.send_input(payload.encode()) + + while True: + try: + chunk = await asyncio.wait_for(self._stdout.__anext__(), timeout=120) + except StopAsyncIteration: + self._execution = None + break + except asyncio.TimeoutError: + break + + chunk_str = ( + chunk.decode("utf-8", errors="replace") if isinstance(chunk, bytes) else chunk + ) + self._buffer += chunk_str - return Result(exec_result.exit_code, "".join(stdout_lines), "".join(stderr_lines)) + while "\n" in self._buffer: + line, self._buffer = self._buffer.split("\n", 1) + line = line.strip() + if not line: + continue + try: + parsed = json.loads(line) + except json.JSONDecodeError: + continue + + if parsed.get("session_id"): + self._claude_session_id = parsed["session_id"] + + yield parsed + + if parsed.get("type") == "result": + return + + @property + def stderr_output(self) -> str: + """Get captured stderr output (useful for debugging failures).""" + return "".join(self._stderr_lines) def __repr__(self) -> str: - return f"ClaudeBox(id={self.id}, session_id={self.session_id})" + box_id = self._box.id if self._box else "" + return f"ClaudeBox(id={box_id}, session_id={self.session_id})" # Enhanced observability methods (Phase 3 Week 7) diff --git a/src/claudebox/templates.py b/src/claudebox/templates.py index b2163e4..1e31fcb 100644 --- a/src/claudebox/templates.py +++ b/src/claudebox/templates.py @@ -8,12 +8,12 @@ class SandboxTemplate(str, Enum): """Pre-configured sandbox templates for different use cases.""" - DEFAULT = "ghcr.io/boxlite-labs/claudebox-runtime:latest" - WEB_DEV = "ghcr.io/boxlite-labs/claudebox-runtime:web-dev" - DATA_SCIENCE = "ghcr.io/boxlite-labs/claudebox-runtime:data-science" - SECURITY = "ghcr.io/boxlite-labs/claudebox-runtime:security" - DEVOPS = "ghcr.io/boxlite-labs/claudebox-runtime:devops" - MOBILE = "ghcr.io/boxlite-labs/claudebox-runtime:mobile" + DEFAULT = "ghcr.io/boxlite-ai/claudebox-runtime:latest" + WEB_DEV = "ghcr.io/boxlite-ai/claudebox-runtime:web-dev" + DATA_SCIENCE = "ghcr.io/boxlite-ai/claudebox-runtime:data-science" + SECURITY = "ghcr.io/boxlite-ai/claudebox-runtime:security" + DEVOPS = "ghcr.io/boxlite-ai/claudebox-runtime:devops" + MOBILE = "ghcr.io/boxlite-ai/claudebox-runtime:mobile" def __str__(self) -> str: return self.value @@ -42,7 +42,7 @@ def get_template_image(template: SandboxTemplate | str) -> str: """ if isinstance(template, str): # Allow custom image URLs - if template.startswith("ghcr.io/") or template.startswith("docker.io/"): + if "/" in template and ("." in template.split("/")[0]): return template # Try to match to enum try: diff --git a/tests/conftest.py b/tests/conftest.py index 1d90fcf..b0564ae 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,7 +28,7 @@ def mock_boxlite_runtime(): Returns a Mock object with create(), get(), list_info(), and remove() methods. """ runtime = Mock() - runtime.create = Mock() + runtime.create = AsyncMock() runtime.get = Mock() runtime.list_info = Mock(return_value=[]) runtime.remove = Mock() @@ -60,9 +60,20 @@ async def async_exit(*args, **kwargs): async def mock_exec(cmd, args=None, env=None): execution = Mock() + # Mock stdin stream (needed for interactive mode) + stdin = Mock() + stdin.send_input = AsyncMock() + stdin.close = AsyncMock() + execution.stdin = Mock(return_value=stdin) + # Mock stdout/stderr streams async def stdout_stream(): - yield b'{"result": "success", "is_error": false}\n' + # Setup command (sh -c) returns empty stdout + if cmd == "sh": + return + # Claude CLI (via su -c) returns NDJSON messages + yield b'{"type":"system","subtype":"init","session_id":"default"}\n' + yield b'{"type":"result","result":"success","is_error":false,"total_cost_usd":0,"duration_ms":100}\n' async def stderr_stream(): return diff --git a/tests/test_real_templates.py b/tests/test_real_templates.py index 836b1e2..9237344 100644 --- a/tests/test_real_templates.py +++ b/tests/test_real_templates.py @@ -62,7 +62,7 @@ async def test_03_template_string(ensure_oauth_token, temp_workspace): oauth_token=ensure_oauth_token, workspace_dir=temp_workspace, session_id="template-test-string", - template="ghcr.io/boxlite-labs/claudebox-runtime:latest", + template="ghcr.io/boxlite-ai/claudebox-runtime:latest", ) as box: assert box.id print(" ✅ Template string works") @@ -84,7 +84,7 @@ async def test_04_explicit_image_overrides_template(ensure_oauth_token, temp_wor workspace_dir=temp_workspace, session_id="template-test-override", template=SandboxTemplate.WEB_DEV, # This should be ignored - image="ghcr.io/boxlite-labs/claudebox-runtime:latest", # This takes priority + image="ghcr.io/boxlite-ai/claudebox-runtime:latest", # This takes priority ) as box: assert box.id print(" ✅ Explicit image parameter takes priority") @@ -170,7 +170,7 @@ async def test_08_custom_image_url(ensure_oauth_token, temp_workspace): oauth_token=ensure_oauth_token, workspace_dir=temp_workspace, session_id="template-custom-url", - template="ghcr.io/boxlite-labs/claudebox-runtime:latest", + template="ghcr.io/boxlite-ai/claudebox-runtime:latest", ) as box: assert box.id print(" ✅ Custom image URL works")