diff --git a/.changeset/pty-support.md b/.changeset/pty-support.md new file mode 100644 index 00000000..1aa105df --- /dev/null +++ b/.changeset/pty-support.md @@ -0,0 +1,30 @@ +--- +'@cloudflare/sandbox': minor +--- + +Add PTY (pseudo-terminal) support for interactive terminal sessions. + +New `sandbox.pty` namespace with: + +- `create()` - Create a new PTY session +- `attach(sessionId)` - Attach PTY to existing session +- `getById(id)` - Reconnect to existing PTY +- `list()` - List all PTY sessions + +PTY handles support: + +- `write(data)` - Send input +- `resize(cols, rows)` - Resize terminal +- `kill()` - Terminate PTY +- `onData(cb)` - Receive output +- `onExit(cb)` - Handle exit +- `exited` - Promise for exit code +- Async iteration for scripting + +Example: + +```typescript +const pty = await sandbox.pty.create({ cols: 80, rows: 24 }); +pty.onData((data) => terminal.write(data)); +pty.write('ls -la\n'); +``` diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml index cf74e652..ebaf28d3 100644 --- a/.github/workflows/pullrequest.yml +++ b/.github/workflows/pullrequest.yml @@ -107,8 +107,6 @@ jobs: set -e VERSION="${{ needs.unit-tests.outputs.version }}" - echo "Starting parallel builds..." - docker buildx build \ --cache-from type=gha,scope=sandbox-base \ --cache-to type=gha,mode=max,scope=sandbox-base \ diff --git a/.github/workflows/sync-docs.yml b/.github/workflows/sync-docs.yml index 21f16180..62dfe4e1 100644 --- a/.github/workflows/sync-docs.yml +++ b/.github/workflows/sync-docs.yml @@ -53,23 +53,35 @@ jobs: - name: Create prompt for OpenCode id: create-prompt + env: + PR_BODY: ${{ github.event.pull_request.body }} run: | # Store PR metadata in environment variables to avoid shell escaping issues PR_NUMBER="${{ github.event.pull_request.number }}" PR_TITLE="${{ github.event.pull_request.title }}" PR_URL="https://github.com/${{ github.repository }}/pull/${{ github.event.pull_request.number }}" - cat > /tmp/claude_prompt.md << EOF + # Use quoted heredoc to prevent interpretation of special characters + cat > /tmp/claude_prompt.md <<'STATIC_EOF' # Intelligent Documentation Sync Task ## Context - - **Source Repository:** ${{ github.repository }} - - **Original PR Number:** ${PR_NUMBER} - - **Original PR Title:** ${PR_TITLE} - - **Original PR URL:** ${PR_URL} - - **Changed Files:** ${{ steps.changed-files.outputs.changed_files }} - - **PR Description:** - ${{ github.event.pull_request.body }} + STATIC_EOF + + # Append dynamic content safely using printf + { + echo "- **Source Repository:** ${{ github.repository }}" + echo "- **Original PR Number:** ${PR_NUMBER}" + echo "- **Original PR Title:** ${PR_TITLE}" + echo "- **Original PR URL:** ${PR_URL}" + echo "- **Changed Files:** ${{ steps.changed-files.outputs.changed_files }}" + echo "- **PR Description:**" + printf '%s\n' "$PR_BODY" + } >> /tmp/claude_prompt.md + + # Append the rest of the static content + cat >> /tmp/claude_prompt.md <<'STATIC_EOF' + ⚠️ **IMPORTANT**: All PR metadata above is the ONLY source of truth. Use these values EXACTLY as written. DO NOT fetch PR title/URL from GitHub APIs. @@ -78,12 +90,12 @@ jobs: **First, gather the information you need:** 1. Review the list of changed files above - 2. Use \`gh pr diff ${PR_NUMBER}\` to see the full diff for this PR + 2. Use `gh pr diff PR_NUMBER_HERE` (replace with actual number from Context) to see the full diff for this PR 3. Use the Read tool to examine specific files if needed You have access to two repositories: - 1. The current directory (our main repo at ${{ github.repository }}) - 2. ./cloudflare-docs (already cloned with branch sync-docs-pr-${{ github.event.pull_request.number }} checked out) + 1. The current directory (our main repo) + 2. ./cloudflare-docs (already cloned with branch sync-docs-pr-PR_NUMBER_HERE checked out) **Step 1: Evaluate if Documentation Sync is Needed** @@ -110,48 +122,33 @@ jobs: If you determine documentation updates are required, YOU MUST COMPLETE ALL STEPS: - 1. Navigate to ./cloudflare-docs (already cloned, branch sync-docs-pr-${PR_NUMBER} checked out) + 1. Navigate to ./cloudflare-docs (already cloned, branch checked out) 2. Adapt changes for cloudflare-docs repository structure and style 3. Create or update the appropriate markdown files 4. Ensure content follows cloudflare-docs conventions 5. **CRITICAL - Commit changes:** - Run exactly: `cd cloudflare-docs && git add . && git commit -m "Sync docs from cloudflare/sandbox-sdk#${PR_NUMBER}: ${PR_TITLE}"` + Run: cd cloudflare-docs && git add . && git commit -m "Sync docs from cloudflare/sandbox-sdk#PR_NUMBER: PR_TITLE" + (Replace PR_NUMBER and PR_TITLE with actual values from Context section) 6. **CRITICAL - Push to remote:** - Run exactly: `cd cloudflare-docs && git push origin sync-docs-pr-${PR_NUMBER}` + Run: cd cloudflare-docs && git push origin sync-docs-pr-PR_NUMBER + (Replace PR_NUMBER with actual value from Context section) 7. **CRITICAL - Create or update PR in cloudflare-docs:** - ⚠️ **CRITICAL**: Use the exact PR_NUMBER, PR_TITLE, and PR_URL values from the Context section above. - - Copy and run this EXACT script (replace PLACEHOLDER values with actual values from Context): - ```bash - # Set variables from Context section above - PR_NUMBER="PLACEHOLDER_PR_NUMBER" - PR_TITLE="PLACEHOLDER_PR_TITLE" - PR_URL="PLACEHOLDER_PR_URL" + Use the exact PR_NUMBER, PR_TITLE, and PR_URL values from the Context section above. - # Check if PR already exists - EXISTING_PR=$(gh pr list --repo cloudflare/cloudflare-docs --head sync-docs-pr-${PR_NUMBER} --json number --jq '.[0].number') + Check if PR exists: gh pr list --repo cloudflare/cloudflare-docs --head sync-docs-pr-PR_NUMBER --json number --jq '.[0].number' - # Create PR title and body - PR_TITLE_TEXT="πŸ“š Sync docs from cloudflare/sandbox-sdk#${PR_NUMBER}: ${PR_TITLE}" - PR_BODY_TEXT="Syncs documentation changes from [cloudflare/sandbox-sdk#${PR_NUMBER}](${PR_URL}): **${PR_TITLE}**" + If PR exists, update it: + gh pr edit EXISTING_PR_NUMBER --repo cloudflare/cloudflare-docs --title "Sync docs from cloudflare/sandbox-sdk#PR_NUMBER: PR_TITLE" --body "Syncs documentation changes from cloudflare/sandbox-sdk#PR_NUMBER (PR_URL): PR_TITLE" - if [ -n "$EXISTING_PR" ] && [ "$EXISTING_PR" != "null" ]; then - # Update existing PR - echo "Updating existing PR #$EXISTING_PR" - gh pr edit $EXISTING_PR --repo cloudflare/cloudflare-docs --title "$PR_TITLE_TEXT" --body "$PR_BODY_TEXT" - else - # Create new PR - echo "Creating new PR" - gh pr create --repo cloudflare/cloudflare-docs --base main --head sync-docs-pr-${PR_NUMBER} --title "$PR_TITLE_TEXT" --body "$PR_BODY_TEXT" - fi - ``` + If PR does not exist, create it: + gh pr create --repo cloudflare/cloudflare-docs --base main --head sync-docs-pr-PR_NUMBER --title "Sync docs from cloudflare/sandbox-sdk#PR_NUMBER: PR_TITLE" --body "Syncs documentation changes from cloudflare/sandbox-sdk#PR_NUMBER (PR_URL): PR_TITLE" - ⚠️ THE TASK IS NOT COMPLETE UNTIL ALL 7 STEPS ARE DONE. Do not stop after editing files. + THE TASK IS NOT COMPLETE UNTIL ALL 7 STEPS ARE DONE. Do not stop after editing files. - ## Documentation Writing Guidelines (DiΓ‘taxis Framework) + ## Documentation Writing Guidelines (Diataxis Framework) When writing the documentation content, adapt your approach based on what changed: @@ -175,54 +172,54 @@ jobs: - Single functions = reference + example (concise) - Multi-step workflows = separate how-to guides - Keep reference neutral and factual - - Don't overexplain simple functions + - Do not overexplain simple functions ## Cloudflare Docs Style Requirements - **CRITICAL**: Follow all rules from the [Cloudflare Style Guide](https://developers.cloudflare.com/style-guide/) and these specific requirements: + **CRITICAL**: Follow all rules from the Cloudflare Style Guide (https://developers.cloudflare.com/style-guide/) and these specific requirements: **Grammar & Formatting:** - - Do not use contractions, exclamation marks, or non-standard quotes like \`''""\` + - Do not use contractions, exclamation marks, or non-standard quotes - Fix common spelling errors, specifically misspellings of "wrangler" - Remove whitespace characters from the end of lines - Remove duplicate words - Do not use HTML for ordered lists **Links:** - - Use full relative links (\`/sandbox-sdk/configuration/\`) instead of full URLs, local dev links, or dot notation + - Use full relative links (/sandbox-sdk/configuration/) instead of full URLs, local dev links, or dot notation - Always use trailing slashes for links without anchors - Use meaningful link words (page titles) - avoid "here", "this page", "read more" - Add cross-links to relevant documentation pages where appropriate **Components (MUST USE):** - - All components need to be imported below frontmatter: \`import { ComponentName } from "~/components";\` - - **WranglerConfig component**: Replace \`toml\` or \`json\` code blocks showing Wrangler configuration with the [\`WranglerConfig\` component](https://developers.cloudflare.com/style-guide/components/wrangler-config/). This is CRITICAL - always use this component for wrangler.toml/wrangler.jsonc examples. - - **DashButton component**: Replace \`https://dash.cloudflare.com\` in steps with the [\`DashButton\` component](https://developers.cloudflare.com/style-guide/components/dash-button/) - - **APIRequest component**: Replace \`sh\` code blocks with API requests to \`https://api.cloudflare.com\` with the [\`APIRequest\` component](https://developers.cloudflare.com/style-guide/components/api-request/) - - **FileTree component**: Replace \`txt\` blocks showing file trees with the [\`FileTree\` component](https://developers.cloudflare.com/style-guide/components/file-tree/) - - **PackageManagers component**: Replace \`sh\` blocks with npm commands using the [\`PackageManagers\` component](https://developers.cloudflare.com/style-guide/components/package-managers/) - - **TypeScriptExample component**: Replace \`ts\`/\`typescript\` code blocks with the [\`TypeScriptExample\` component](https://developers.cloudflare.com/style-guide/components/typescript-example/) (except in step-by-step TypeScript-specific tutorials) + - All components need to be imported below frontmatter: import { ComponentName } from "~/components"; + - **WranglerConfig component**: Replace toml or json code blocks showing Wrangler configuration with the WranglerConfig component. This is CRITICAL - always use this component for wrangler.toml/wrangler.jsonc examples. + - **DashButton component**: Replace https://dash.cloudflare.com in steps with the DashButton component + - **APIRequest component**: Replace sh code blocks with API requests to https://api.cloudflare.com with the APIRequest component + - **FileTree component**: Replace txt blocks showing file trees with the FileTree component + - **PackageManagers component**: Replace sh blocks with npm commands using the PackageManagers component + - **TypeScriptExample component**: Replace ts/typescript code blocks with the TypeScriptExample component (except in step-by-step TypeScript-specific tutorials) **JSX & Partials:** - When using JSX fragments for conditional rendering, use props variable to account for reusability - - Only use \`\` component in JSX conditionals, and only if needed + - Only use Markdown component in JSX conditionals, and only if needed - Do not duplicate content in ternary/binary conditions - For variables in links, use HTML instead of Markdown **Step 3: Provide Clear Output** Clearly state your decision: - - If syncing: Explain what documentation changes you're making and why - - If not syncing: Explain why documentation updates aren't needed for this PR + - If syncing: Explain what documentation changes you are making and why + - If not syncing: Explain why documentation updates are not needed for this PR ## Important Notes - Use the GH_TOKEN environment variable for authentication with gh CLI - Adapt paths, links, and references as needed for cloudflare-docs structure - Follow existing patterns in the cloudflare-docs repository - - **DEFAULT TO SYNCING**: When in doubt about whether changes warrant a sync, ALWAYS create the sync PR for human review. It's better to create an unnecessary PR than to miss important documentation updates. + - **DEFAULT TO SYNCING**: When in doubt about whether changes warrant a sync, ALWAYS create the sync PR for human review. It is better to create an unnecessary PR than to miss important documentation updates. Begin your evaluation now. - EOF + STATIC_EOF echo "prompt<> $GITHUB_OUTPUT cat /tmp/claude_prompt.md >> $GITHUB_OUTPUT diff --git a/examples/collaborative-terminal/Dockerfile b/examples/collaborative-terminal/Dockerfile new file mode 100644 index 00000000..d78bb1fe --- /dev/null +++ b/examples/collaborative-terminal/Dockerfile @@ -0,0 +1,17 @@ +# Collaborative Terminal Dockerfile +# +# IMPORTANT: PTY support requires sandbox image version 0.7.0 or later. +# +# For local development with PTY support, first build the base image: +# cd ../.. # Go to monorepo root +# docker build --platform linux/amd64 -f packages/sandbox/Dockerfile --target default -t sandbox-pty-local . +# +# The wrangler dev server will then use this Dockerfile which extends sandbox-pty-local. +# +FROM sandbox-local + +# Create home directory for terminal sessions +RUN mkdir -p /home/user && chmod 777 /home/user + +# Expose container port +EXPOSE 3000 diff --git a/examples/collaborative-terminal/README.md b/examples/collaborative-terminal/README.md new file mode 100644 index 00000000..c2a76ba8 --- /dev/null +++ b/examples/collaborative-terminal/README.md @@ -0,0 +1,165 @@ +# Collaborative Terminal + +**Google Docs for Bash** - A multi-user terminal where multiple people can see the same PTY output in real-time and take turns sending commands. + +This example demonstrates: + +- **PTY Support**: Using the Sandbox SDK's PTY API for interactive terminal sessions +- **WebSocket Streaming**: Real-time output broadcast to all connected users +- **Collaborative Workflows**: Multiple users sharing a single terminal session +- **Presence Indicators**: See who's connected and who's typing + +## Features + +- Create terminal rooms that others can join via shareable link +- Real-time terminal output synchronized across all participants +- User presence list with colored indicators +- "Typing" indicators showing who's sending commands +- Terminal history buffering so new users see previous output +- Automatic cleanup when all users disconnect + +## Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” WebSocket β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” PTY API β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Browser │◄──────────────────► Cloudflare │◄──────────────►│ Sandbox β”‚ +β”‚ (xterm) β”‚ β”‚ Worker β”‚ β”‚ Container β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ + β”‚ β”‚ + β–Ό β–Ό + User Input Broadcast PTY + ─────────► output to all + connected users +``` + +## Getting Started + +### Prerequisites + +- Node.js 20+ +- Docker (for local development) +- Cloudflare account with container access (beta) + +### Important: PTY Support Required + +This example requires PTY (pseudo-terminal) support which is available in sandbox image version **0.7.0 or later**. If using an older image, you'll need to build a local image with PTY support: + +```bash +# From the monorepo root +cd ../.. +docker build -f packages/sandbox/Dockerfile --target default -t sandbox-local . + +# Then update examples/collaborative-terminal/Dockerfile to use: +# FROM sandbox-local +``` + +### Installation + +```bash +cd examples/collaborative-terminal +npm install +``` + +### Development + +```bash +npm run dev +``` + +This starts a local development server. Open http://localhost:5173 to access the app. + +### Deploy + +```bash +npm run deploy +``` + +## Usage + +1. **Create a Room**: Click "Create New Room" to start a new terminal session +2. **Share the Link**: Click "Copy Link" to share the room with others +3. **Join a Room**: Enter a room ID to join an existing session +4. **Start Terminal**: Click "Start Terminal Session" to launch bash +5. **Collaborate**: All connected users see the same terminal output and can send commands + +## How It Works + +### Backend (Worker) + +The Cloudflare Worker manages: + +1. **Room State**: Tracks connected users and active PTY sessions per room +2. **WebSocket Connections**: Handles real-time communication with clients +3. **PTY Lifecycle**: Creates/destroys PTY sessions via the Sandbox SDK +4. **Output Broadcasting**: Forwards PTY output to all connected WebSocket clients + +```typescript +// Create PTY and subscribe to output +const pty = await sandbox.pty.create({ + cols: 80, + rows: 24, + command: ['/bin/bash'] +}); + +pty.onData((data) => { + // Broadcast to all connected users + broadcast(roomId, { type: 'pty_output', data }); +}); +``` + +### Frontend (React + xterm.js) + +The React app provides: + +1. **Terminal Rendering**: Uses xterm.js for terminal emulation +2. **WebSocket Client**: Connects to the worker for real-time updates +3. **User Management**: Displays connected users with presence indicators +4. **Input Handling**: Forwards keystrokes to the shared PTY + +```typescript +// Handle terminal input +term.onData((data) => { + ws.send(JSON.stringify({ type: 'pty_input', data })); +}); + +// Handle PTY output from server +ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + if (msg.type === 'pty_output') { + term.write(msg.data); + } +}; +``` + +## API Reference + +### WebSocket Messages + +**Client β†’ Server:** + +| Type | Description | Fields | +| ------------ | ------------------ | -------------- | +| `start_pty` | Create PTY session | `cols`, `rows` | +| `pty_input` | Send input to PTY | `data` | +| `pty_resize` | Resize terminal | `cols`, `rows` | + +**Server β†’ Client:** + +| Type | Description | Fields | +| ------------- | ------------------- | ---------------------------- | +| `connected` | Initial connection | `userId`, `users`, `history` | +| `user_joined` | User joined room | `user`, `users` | +| `user_left` | User left room | `userId`, `users` | +| `pty_started` | PTY session created | `ptyId` | +| `pty_output` | Terminal output | `data` | +| `pty_exit` | PTY session ended | `exitCode` | +| `user_typing` | User sent input | `user` | + +## Customization Ideas + +- **Access Control**: Add authentication to restrict who can join/type +- **Command History**: Store and replay command history +- **Multiple Terminals**: Support multiple PTY sessions per room +- **Recording**: Record sessions for playback +- **Chat**: Add a sidebar chat for discussion diff --git a/examples/collaborative-terminal/index.html b/examples/collaborative-terminal/index.html new file mode 100644 index 00000000..67b95218 --- /dev/null +++ b/examples/collaborative-terminal/index.html @@ -0,0 +1,36 @@ + + + + + + + + + + + Collaborative Terminal | Cloudflare Sandbox + + + + + + + +
+ + + diff --git a/examples/collaborative-terminal/package.json b/examples/collaborative-terminal/package.json new file mode 100644 index 00000000..cd175f75 --- /dev/null +++ b/examples/collaborative-terminal/package.json @@ -0,0 +1,41 @@ +{ + "name": "@cloudflare/sandbox-collaborative-terminal-example", + "version": "1.0.0", + "description": "Collaborative terminal - Google Docs for bash using PTY support", + "private": true, + "scripts": { + "dev": "vite dev", + "build": "vite build", + "deploy": "vite build && wrangler deploy", + "types": "wrangler types env.d.ts --include-runtime false", + "typecheck": "tsc --noEmit" + }, + "keywords": [ + "sandbox", + "pty", + "terminal", + "collaborative" + ], + "author": "", + "license": "ISC", + "type": "module", + "dependencies": { + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/addon-webgl": "^0.19.0", + "@xterm/xterm": "^5.5.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@cloudflare/sandbox": "*", + "@cloudflare/vite-plugin": "^1.20.1", + "@cloudflare/workers-types": "^4.20251126.0", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "typescript": "^5.9.3", + "vite": "^7.2.4", + "wrangler": "^4.50.0" + } +} diff --git a/examples/collaborative-terminal/src/App.tsx b/examples/collaborative-terminal/src/App.tsx new file mode 100644 index 00000000..ac0c7968 --- /dev/null +++ b/examples/collaborative-terminal/src/App.tsx @@ -0,0 +1,1389 @@ +import { FitAddon } from '@xterm/addon-fit'; +import { WebLinksAddon } from '@xterm/addon-web-links'; +import { Terminal } from '@xterm/xterm'; +import '@xterm/xterm/css/xterm.css'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +// Custom terminal theme - inspired by modern terminals with Cloudflare orange accents +const terminalTheme = { + // Base colors + background: '#0c0c0c', + foreground: '#d4d4d8', + cursor: '#f97316', + cursorAccent: '#0c0c0c', + selectionBackground: '#f9731640', + selectionForeground: '#ffffff', + selectionInactiveBackground: '#f9731620', + + // Normal colors (ANSI 0-7) + black: '#09090b', + red: '#f87171', + green: '#4ade80', + yellow: '#fbbf24', + blue: '#60a5fa', + magenta: '#c084fc', + cyan: '#22d3ee', + white: '#e4e4e7', + + // Bright colors (ANSI 8-15) + brightBlack: '#52525b', + brightRed: '#fca5a5', + brightGreen: '#86efac', + brightYellow: '#fde047', + brightBlue: '#93c5fd', + brightMagenta: '#d8b4fe', + brightCyan: '#67e8f9', + brightWhite: '#fafafa' +}; + +interface User { + id: string; + name: string; + color: string; +} + +interface AppState { + connected: boolean; + roomId: string | null; + userId: string | null; + userName: string | null; + userColor: string | null; + users: User[]; + hasActivePty: boolean; + typingUser: User | null; +} + +function generateRandomUserSuffix(): string { + const array = new Uint32Array(1); + window.crypto.getRandomValues(array); + return array[0].toString(36).slice(0, 4); +} + +export function App() { + const [state, setState] = useState({ + connected: false, + roomId: null, + userId: null, + userName: null, + userColor: null, + users: [], + hasActivePty: false, + typingUser: null + }); + + const [joinName, setJoinName] = useState(''); + const [joinRoomId, setJoinRoomId] = useState(''); + const [copied, setCopied] = useState(false); + + const terminalRef = useRef(null); + const xtermRef = useRef(null); + const fitAddonRef = useRef(null); + const wsRef = useRef(null); + const typingTimeoutRef = useRef | null>(null); + + // Initialize terminal when connected (terminal div becomes available) + useEffect(() => { + // Only initialize when connected and terminal div exists + if (!state.connected || !terminalRef.current || xtermRef.current) return; + + console.log('[App] Initializing terminal...'); + const term = new Terminal({ + cursorBlink: true, + cursorStyle: 'bar', + cursorWidth: 2, + theme: terminalTheme, + fontSize: 15, + fontFamily: + '"JetBrains Mono", "Fira Code", "SF Mono", Menlo, Monaco, "Courier New", monospace', + fontWeight: '400', + fontWeightBold: '600', + letterSpacing: 0, + lineHeight: 1.3, + scrollback: 10000, + convertEol: true + }); + + const fitAddon = new FitAddon(); + const webLinksAddon = new WebLinksAddon(); + + term.loadAddon(fitAddon); + term.loadAddon(webLinksAddon); + + term.open(terminalRef.current); + fitAddon.fit(); + + xtermRef.current = term; + fitAddonRef.current = fitAddon; + console.log( + '[App] Terminal initialized, cols:', + term.cols, + 'rows:', + term.rows + ); + + // Handle resize + const handleResize = () => { + fitAddon.fit(); + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send( + JSON.stringify({ + type: 'pty_resize', + cols: term.cols, + rows: term.rows + }) + ); + } + }; + + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + term.dispose(); + xtermRef.current = null; + fitAddonRef.current = null; + }; + }, [state.connected]); + + // Handle WebSocket messages + const handleWsMessage = useCallback((event: MessageEvent) => { + const message = JSON.parse(event.data); + console.log('[App] WS message received:', message.type, message); + const term = xtermRef.current; + + switch (message.type) { + case 'connected': + setState((s) => ({ + ...s, + connected: true, + userId: message.userId, + userName: message.userName, + userColor: message.userColor, + users: message.users, + hasActivePty: message.hasActivePty + })); + // Write history to terminal + if (message.history && term) { + term.write(message.history); + } + break; + + case 'user_joined': + setState((s) => ({ ...s, users: message.users })); + break; + + case 'user_left': + setState((s) => ({ ...s, users: message.users })); + break; + + case 'pty_started': + // PTY started - output will come via WebSocket (pty_output messages) + setState((s) => ({ ...s, hasActivePty: true })); + console.log('[App] PTY started:', message.ptyId); + break; + + case 'pty_output': + // PTY output broadcast via WebSocket + if (term) { + term.write(message.data); + } + break; + + case 'pty_exit': + setState((s) => ({ ...s, hasActivePty: false })); + if (term) { + term.writeln(`\r\n[Process exited with code ${message.exitCode}]`); + } + break; + + case 'user_typing': + setState((s) => ({ ...s, typingUser: message.user })); + // Clear typing indicator after 1 second + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + } + typingTimeoutRef.current = setTimeout(() => { + setState((s) => ({ ...s, typingUser: null })); + }, 1000); + break; + + case 'error': + console.error('Server error:', message.message); + if (term) { + term.writeln(`\r\n\x1b[31m[Error: ${message.message}]\x1b[0m`); + } + break; + } + }, []); + + // Connect to room + const connectToRoom = useCallback( + (roomId: string, userName: string) => { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const ws = new WebSocket( + `${protocol}//${window.location.host}/ws/room/${roomId}?name=${encodeURIComponent(userName)}` + ); + + ws.addEventListener('open', () => { + wsRef.current = ws; + setState((s) => ({ ...s, roomId })); + // Update URL with room ID so it can be shared + const newUrl = `${window.location.origin}?room=${roomId}`; + window.history.replaceState({}, '', newUrl); + }); + + ws.addEventListener('message', handleWsMessage); + + ws.addEventListener('close', () => { + wsRef.current = null; + setState((s) => ({ + ...s, + connected: false, + roomId: null, + users: [], + hasActivePty: false + })); + }); + + ws.addEventListener('error', (err) => { + console.error('WebSocket error:', err); + }); + }, + [handleWsMessage] + ); + + // Start PTY session + const startPty = useCallback(() => { + console.log( + '[App] startPty called, ws state:', + wsRef.current?.readyState, + 'term:', + !!xtermRef.current + ); + if (wsRef.current?.readyState === WebSocket.OPEN && xtermRef.current) { + const msg = { + type: 'start_pty', + cols: xtermRef.current.cols, + rows: xtermRef.current.rows + }; + console.log('[App] Sending start_pty:', msg); + wsRef.current.send(JSON.stringify(msg)); + } else { + console.warn('[App] Cannot start PTY - ws not ready or no terminal'); + } + }, []); + + // Handle terminal input + useEffect(() => { + const term = xtermRef.current; + if (!term) return; + + const disposable = term.onData((data: string) => { + if (wsRef.current?.readyState === WebSocket.OPEN && state.hasActivePty) { + // Debug: log control characters + if (data.charCodeAt(0) < 32) { + console.log( + '[App] Sending control char:', + data.charCodeAt(0), + 'hex:', + data.charCodeAt(0).toString(16) + ); + } + wsRef.current.send( + JSON.stringify({ + type: 'pty_input', + data + }) + ); + } + }); + + return () => disposable.dispose(); + }, [state.hasActivePty]); + + // Create new room + const createRoom = async () => { + const name = joinName.trim() || `User-${generateRandomUserSuffix()}`; + const response = await fetch('/api/room', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userName: name }) + }); + const data = (await response.json()) as { roomId: string }; + connectToRoom(data.roomId, name); + }; + + // Join existing room + const joinRoom = () => { + const name = joinName.trim() || `User-${generateRandomUserSuffix()}`; + const roomId = joinRoomId.trim(); + if (roomId) { + connectToRoom(roomId, name); + } + }; + + // Copy room link + const copyRoomLink = () => { + const link = `${window.location.origin}?room=${state.roomId}`; + navigator.clipboard.writeText(link); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + // Check for room in URL on mount - pre-fill room ID but let user enter name + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const roomFromUrl = params.get('room'); + if (roomFromUrl) { + setJoinRoomId(roomFromUrl); + // Don't auto-join - let user enter their name first + } + }, []); + + return ( +
+ {/* Animated background gradient */} +
+
+ + {!state.connected ? ( +
+
+
+ + Sandbox +
+
+ +
+
+ + Powered by Cloudflare Sandboxes +
+

+ Collaborative +
+ Terminal +

+

+ Real-time terminal sharing. Like Google Docs, but for your shell. +
+ Code together, debug together, ship together. +

+ +
+
+ + setJoinName(e.target.value)} + className="input" + /> +
+ + + +
+ or join existing +
+ +
+ setJoinRoomId(e.target.value)} + className="input" + /> + +
+
+ +
+
+ + Multi-user +
+
+ + Real-time sync +
+
+ + Secure isolation +
+
+
+
+ ) : ( +
+ {/* Top bar */} +
+
+
+ +
+
+ Room + {state.roomId} + +
+
+ +
+
+ {state.users.map((user, idx) => ( +
+ {user.name.charAt(0).toUpperCase()} + {state.typingUser?.id === user.id && ( + + )} +
+ ))} +
+
+ + {state.users.length} online +
+
+
+ + {/* Terminal area */} +
+ {/* Floating clouds */} +
+
+
+
+
+
+
+
+ {/* Window chrome */} +
+
+ + + +
+
+ {state.hasActivePty ? ( + <> + $ + bash β€” {xtermRef.current?.cols}x{xtermRef.current?.rows} + + ) : ( + 'Terminal' + )} +
+
+ {!state.hasActivePty && ( + + )} +
+
+ + {/* Terminal content */} +
+
+ {!state.hasActivePty && ( +
+
+
+ +
+

Ready to collaborate

+

+ Start a terminal session to begin. All participants will + see the same output in real-time. +

+ +
+
+ )} +
+
+
+
+ )} + + +
+ ); +} diff --git a/examples/collaborative-terminal/src/index.ts b/examples/collaborative-terminal/src/index.ts new file mode 100644 index 00000000..6e7248c6 --- /dev/null +++ b/examples/collaborative-terminal/src/index.ts @@ -0,0 +1,434 @@ +/** + * Collaborative Terminal - "Google Docs for Bash" + * + * This example demonstrates how to build a multi-user terminal where: + * - Multiple users can connect to the same PTY session + * - Everyone sees the same terminal output in real-time + * - Users can take turns sending commands + * - Presence indicators show who's connected + * + * Architecture: + * - A separate Room Durable Object manages collaboration/presence + * - The Room DO uses getSandbox() to interact with a shared Sandbox + * - PTY I/O uses WebSocket connection to container for low latency + */ + +import { getSandbox, Sandbox } from '@cloudflare/sandbox'; + +// Re-export Sandbox for wrangler +export { Sandbox }; + +interface Env { + Sandbox: DurableObjectNamespace; + Room: DurableObjectNamespace; +} + +// User info for presence +interface UserInfo { + id: string; + name: string; + color: string; +} + +// Generate a short, random suffix for default user names using +// cryptographically secure randomness instead of Math.random(). +function generateRandomNameSuffix(): string { + const bytes = new Uint8Array(4); + crypto.getRandomValues(bytes); + // Convert bytes to a base-36 string and take 4 characters, similar length + // to the original Math.random().toString(36).slice(2, 6). + const num = (bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]; + const str = Math.abs(num).toString(36); + return str.slice(0, 4).padEnd(4, '0'); +} + +// Client connection +interface ClientConnection { + ws: WebSocket; + info: UserInfo; +} + +// Generate random user color +function randomColor(): string { + const colors = [ + '#FF6B6B', + '#4ECDC4', + '#45B7D1', + '#96CEB4', + '#FFEAA7', + '#DDA0DD', + '#98D8C8', + '#F7DC6F', + '#BB8FCE', + '#85C1E9' + ]; + return colors[Math.floor(Math.random() * colors.length)]; +} + +// Room Durable Object - handles collaboration/presence separately from Sandbox +export class Room implements DurableObject { + private clients: Map = new Map(); + private ptyId: string | null = null; + private outputBuffer: string[] = []; + private containerWs: WebSocket | null = null; + private roomId: string = ''; + private env: Env; + + constructor(_ctx: DurableObjectState, env: Env) { + this.env = env; + } + + // Get all connected users + private getConnectedUsers(): UserInfo[] { + return Array.from(this.clients.values()).map((c) => c.info); + } + + // Broadcast to all connected WebSockets + private broadcast(message: object, excludeUserId?: string): void { + const data = JSON.stringify(message); + for (const [userId, client] of this.clients) { + if (userId !== excludeUserId) { + try { + client.ws.send(data); + } catch { + // Client disconnected + } + } + } + } + + // Handle PTY start + private async startPty( + ws: WebSocket, + cols: number, + rows: number + ): Promise { + if (this.ptyId) { + // PTY already exists + ws.send(JSON.stringify({ type: 'pty_started', ptyId: this.ptyId })); + return; + } + + try { + console.log(`[Room ${this.roomId}] Creating PTY...`); + + // Get sandbox instance using helper + const sandbox = getSandbox(this.env.Sandbox, `shared-sandbox`); + + // Colored prompt - user@sandbox with orange accent + const PS1 = + '\\[\\e[38;5;39m\\]user\\[\\e[0m\\]@\\[\\e[38;5;208m\\]sandbox\\[\\e[0m\\] \\[\\e[38;5;41m\\]\\w\\[\\e[0m\\] \\[\\e[38;5;208m\\]❯\\[\\e[0m\\] '; + + // Create PTY session via HTTP API + const ptyResponse = await sandbox.fetch( + new Request('http://container/api/pty', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + cols: cols || 80, + rows: rows || 24, + command: ['/bin/bash', '--norc', '--noprofile'], + cwd: '/home/user', + env: { + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + LANG: 'en_US.UTF-8', + HOME: '/home/user', + USER: 'user', + PS1, + ROOM_ID: this.roomId, + CLICOLOR: '1', + CLICOLOR_FORCE: '1', + FORCE_COLOR: '3', + LS_COLORS: + 'di=1;34:ln=1;36:so=1:35:pi=33:ex=1;32:bd=1;33:cd=1;33:su=1:sg=1:tw=1:ow=1;34' + } + }) + }) + ); + + if (!ptyResponse.ok) { + const errorText = await ptyResponse.text(); + throw new Error(`Failed to create PTY: ${errorText}`); + } + + const ptyResult = (await ptyResponse.json()) as { + success: boolean; + pty: { id: string }; + }; + const ptyId = ptyResult.pty.id; + + console.log(`[Room ${this.roomId}] PTY created: ${ptyId}`); + this.ptyId = ptyId; + + // Establish WebSocket connection to container for PTY streaming + console.log(`[Room ${this.roomId}] Connecting to container WebSocket...`); + const wsRequest = new Request('http://container/ws', { + headers: { + Upgrade: 'websocket', + Connection: 'Upgrade' + } + }); + const wsResponse = await sandbox.fetch(wsRequest); + + if (!wsResponse.webSocket) { + throw new Error( + 'Failed to establish WebSocket connection to container' + ); + } + this.containerWs = wsResponse.webSocket; + this.containerWs.accept(); + console.log(`[Room ${this.roomId}] Container WebSocket connected`); + + // Forward PTY output to all browser clients + this.containerWs.addEventListener('message', (wsEvent) => { + try { + const containerMsg = JSON.parse(wsEvent.data as string); + if (containerMsg.type === 'stream' && containerMsg.data) { + const streamData = JSON.parse(containerMsg.data); + if (streamData.type === 'pty_data' && streamData.data) { + this.outputBuffer.push(streamData.data); + if (this.outputBuffer.length > 1000) { + this.outputBuffer.shift(); + } + this.broadcast({ type: 'pty_output', data: streamData.data }); + } else if (streamData.type === 'pty_exit') { + this.broadcast({ + type: 'pty_exit', + exitCode: streamData.exitCode + }); + this.ptyId = null; + this.containerWs?.close(); + this.containerWs = null; + } + } + } catch (e) { + console.error( + `[Room ${this.roomId}] Container message parse error:`, + e + ); + } + }); + + this.containerWs.addEventListener('error', (e) => { + console.error(`[Room ${this.roomId}] Container WS error:`, e); + }); + + this.containerWs.addEventListener('close', () => { + console.log(`[Room ${this.roomId}] Container WS closed`); + this.containerWs = null; + }); + + // Subscribe to PTY output stream + this.containerWs.send( + JSON.stringify({ + type: 'request', + id: `pty_stream_${ptyId}`, + method: 'GET', + path: `/api/pty/${ptyId}/stream`, + headers: { Accept: 'text/event-stream' } + }) + ); + + // Broadcast pty_started to all clients + console.log(`[Room ${this.roomId}] Broadcasting pty_started`); + this.broadcast({ type: 'pty_started', ptyId }); + } catch (error) { + console.error(`[Room ${this.roomId}] PTY create error:`, error); + ws.send( + JSON.stringify({ + type: 'error', + message: + error instanceof Error ? error.message : 'Failed to create PTY' + }) + ); + } + } + + // Handle client message + private handleClientMessage( + userId: string, + ws: WebSocket, + data: string + ): void { + const client = this.clients.get(userId); + if (!client) return; + + try { + const msg = JSON.parse(data) as { + type: string; + data?: string; + cols?: number; + rows?: number; + }; + + console.log( + `[Room ${this.roomId}] Client message: ${msg.type}`, + msg.type === 'pty_input' ? `data length: ${msg.data?.length}` : '' + ); + + switch (msg.type) { + case 'start_pty': + this.startPty(ws, msg.cols || 80, msg.rows || 24); + break; + + case 'pty_input': + if (this.ptyId && this.containerWs && msg.data) { + // Debug: log control characters + if (msg.data.charCodeAt(0) < 32) { + console.log( + `[Room ${this.roomId}] Sending control char to container: ${msg.data.charCodeAt(0)} (0x${msg.data.charCodeAt(0).toString(16)})` + ); + } + this.containerWs.send( + JSON.stringify({ + type: 'pty_input', + ptyId: this.ptyId, + data: msg.data + }) + ); + this.broadcast({ type: 'user_typing', user: client.info }, userId); + } else { + console.log( + `[Room ${this.roomId}] Cannot send pty_input: ptyId=${this.ptyId}, containerWs=${!!this.containerWs}, data=${!!msg.data}` + ); + } + break; + + case 'pty_resize': + if (this.ptyId && this.containerWs && msg.cols && msg.rows) { + this.containerWs.send( + JSON.stringify({ + type: 'pty_resize', + ptyId: this.ptyId, + cols: msg.cols, + rows: msg.rows + }) + ); + } + break; + } + } catch (error) { + console.error(`[Room ${this.roomId}] Message error:`, error); + } + } + + // Handle client disconnect + private handleClientDisconnect(userId: string): void { + this.clients.delete(userId); + this.broadcast({ + type: 'user_left', + userId, + users: this.getConnectedUsers() + }); + } + + // Handle incoming requests + async fetch(request: Request): Promise { + const url = new URL(request.url); + + // Handle WebSocket upgrade + if (request.headers.get('Upgrade') === 'websocket') { + const userName = + url.searchParams.get('name') || `User-${generateRandomNameSuffix()}`; + this.roomId = url.searchParams.get('roomId') || 'default'; + + // Create WebSocket pair + const pair = new WebSocketPair(); + const [client, server] = Object.values(pair); + server.accept(); + + // Create user info + const userId = crypto.randomUUID(); + const userInfo: UserInfo = { + id: userId, + name: userName, + color: randomColor() + }; + + // Store client + this.clients.set(userId, { ws: server, info: userInfo }); + + // Set up event handlers + server.addEventListener('message', (event) => { + this.handleClientMessage(userId, server, event.data as string); + }); + + server.addEventListener('close', () => { + this.handleClientDisconnect(userId); + }); + + server.addEventListener('error', () => { + this.handleClientDisconnect(userId); + }); + + // Send initial state + server.send( + JSON.stringify({ + type: 'connected', + userId, + userName: userInfo.name, + userColor: userInfo.color, + users: this.getConnectedUsers(), + hasActivePty: this.ptyId !== null, + ptyId: this.ptyId, + history: this.outputBuffer.join('') + }) + ); + + // Notify others + this.broadcast( + { + type: 'user_joined', + user: userInfo, + users: this.getConnectedUsers() + }, + userId + ); + + return new Response(null, { status: 101, webSocket: client }); + } + + return new Response('Not found', { status: 404 }); + } +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + // API: Create a new room + if (url.pathname === '/api/room' && request.method === 'POST') { + const roomId = crypto.randomUUID().slice(0, 8); + return Response.json({ + roomId, + joinUrl: `${url.origin}?room=${roomId}` + }); + } + + // WebSocket: Connect to terminal room + if (url.pathname.startsWith('/ws/room/')) { + const upgradeHeader = request.headers.get('Upgrade'); + if (upgradeHeader !== 'websocket') { + return new Response('Expected WebSocket upgrade', { status: 426 }); + } + + const roomId = url.pathname.split('/')[3]; + const userName = url.searchParams.get('name') || 'Anonymous'; + + // Get Room DO for this room + const id = env.Room.idFromName(`room-${roomId}`); + const room = env.Room.get(id); + + // Forward WebSocket request to Room DO + const wsUrl = new URL(request.url); + wsUrl.searchParams.set('roomId', roomId); + wsUrl.searchParams.set('name', userName); + + return room.fetch(new Request(wsUrl.toString(), request)); + } + + // Serve static files (handled by assets binding) + return new Response('Not found', { status: 404 }); + } +}; diff --git a/examples/collaborative-terminal/src/main.tsx b/examples/collaborative-terminal/src/main.tsx new file mode 100644 index 00000000..b5d80c86 --- /dev/null +++ b/examples/collaborative-terminal/src/main.tsx @@ -0,0 +1,12 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { App } from './App'; + +const root = document.getElementById('root'); +if (root) { + createRoot(root).render( + + + + ); +} diff --git a/examples/collaborative-terminal/tsconfig.json b/examples/collaborative-terminal/tsconfig.json new file mode 100644 index 00000000..36eea301 --- /dev/null +++ b/examples/collaborative-terminal/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "types": ["@cloudflare/workers-types/2023-07-01", "vite/client"] + }, + "include": ["src/**/*", "env.d.ts"], + "exclude": ["node_modules"] +} diff --git a/examples/collaborative-terminal/vite.config.ts b/examples/collaborative-terminal/vite.config.ts new file mode 100644 index 00000000..a32e36dc --- /dev/null +++ b/examples/collaborative-terminal/vite.config.ts @@ -0,0 +1,7 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [react(), cloudflare()] +}); diff --git a/examples/collaborative-terminal/wrangler.jsonc b/examples/collaborative-terminal/wrangler.jsonc new file mode 100644 index 00000000..6cd0aeb2 --- /dev/null +++ b/examples/collaborative-terminal/wrangler.jsonc @@ -0,0 +1,44 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "sandbox-collaborative-terminal", + "main": "src/index.ts", + "compatibility_date": "2025-11-15", + "compatibility_flags": ["nodejs_compat"], + "observability": { + "enabled": true + }, + "vars": {}, + "assets": { + "directory": "public" + }, + "containers": [ + { + "class_name": "Sandbox", + "image": "./Dockerfile", + "instance_type": "standard-2", + "max_instances": 5 + } + ], + "durable_objects": { + "bindings": [ + { + "class_name": "Sandbox", + "name": "Sandbox" + }, + { + "class_name": "Room", + "name": "Room" + } + ] + }, + "migrations": [ + { + "new_sqlite_classes": ["Sandbox"], + "tag": "v1" + }, + { + "new_classes": ["Room"], + "tag": "v2" + } + ] +} diff --git a/examples/openai-agents/package.json b/examples/openai-agents/package.json index 85c183bb..8395b138 100644 --- a/examples/openai-agents/package.json +++ b/examples/openai-agents/package.json @@ -21,7 +21,7 @@ "react-dom": "^19.2.0" }, "devDependencies": { - "@cloudflare/vite-plugin": "^1.15.2", + "@cloudflare/vite-plugin": "^1.20.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", diff --git a/examples/typescript-validator/package.json b/examples/typescript-validator/package.json index 04269b85..70b7075e 100644 --- a/examples/typescript-validator/package.json +++ b/examples/typescript-validator/package.json @@ -20,7 +20,7 @@ "react-dom": "^19.2.0" }, "devDependencies": { - "@cloudflare/vite-plugin": "^1.15.2", + "@cloudflare/vite-plugin": "^1.20.1", "@tailwindcss/vite": "^4.1.17", "@types/node": "^24.10.1", "@vitejs/plugin-react": "^5.1.1", diff --git a/package-lock.json b/package-lock.json index 2f27cab2..d4cf2c35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,6 +70,30 @@ "wrangler": "^4.50.0" } }, + "examples/collaborative-terminal": { + "name": "@cloudflare/sandbox-collaborative-terminal-example", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/addon-webgl": "^0.19.0", + "@xterm/xterm": "^5.5.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@cloudflare/sandbox": "*", + "@cloudflare/vite-plugin": "^1.20.1", + "@cloudflare/workers-types": "^4.20251126.0", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "typescript": "^5.9.3", + "vite": "^7.2.4", + "wrangler": "^4.50.0" + } + }, "examples/minimal": { "name": "@cloudflare/sandbox-minimal-example", "version": "1.0.0", @@ -1264,137 +1288,886 @@ "picocolors": "^1.1.0" } }, - "node_modules/@changesets/should-skip-package": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@changesets/should-skip-package/-/should-skip-package-0.1.2.tgz", - "integrity": "sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@changesets/types": "^6.1.0", - "@manypkg/get-packages": "^1.1.3" + "node_modules/@changesets/should-skip-package": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@changesets/should-skip-package/-/should-skip-package-0.1.2.tgz", + "integrity": "sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/types": "^6.1.0", + "@manypkg/get-packages": "^1.1.3" + } + }, + "node_modules/@changesets/types": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@changesets/types/-/types-6.1.0.tgz", + "integrity": "sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@changesets/write": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@changesets/write/-/write-0.4.0.tgz", + "integrity": "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/types": "^6.1.0", + "fs-extra": "^7.0.1", + "human-id": "^4.1.1", + "prettier": "^2.7.1" + } + }, + "node_modules/@changesets/write/node_modules/prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/@cloudflare/containers": { + "version": "0.0.30", + "resolved": "https://registry.npmjs.org/@cloudflare/containers/-/containers-0.0.30.tgz", + "integrity": "sha512-i148xBgmyn/pje82ZIyuTr/Ae0BT/YWwa1/GTJcw6DxEjUHAzZLaBCiX446U9OeuJ2rBh/L/9FIzxX5iYNt1AQ==", + "license": "ISC" + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.0.tgz", + "integrity": "sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "mime": "^3.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@cloudflare/sandbox": { + "resolved": "packages/sandbox", + "link": true + }, + "node_modules/@cloudflare/sandbox-claude-code-example": { + "resolved": "examples/claude-code", + "link": true + }, + "node_modules/@cloudflare/sandbox-code-interpreter-example": { + "resolved": "examples/code-interpreter", + "link": true + }, + "node_modules/@cloudflare/sandbox-collaborative-terminal-example": { + "resolved": "examples/collaborative-terminal", + "link": true + }, + "node_modules/@cloudflare/sandbox-minimal-example": { + "resolved": "examples/minimal", + "link": true + }, + "node_modules/@cloudflare/sandbox-openai-agents-example": { + "resolved": "examples/openai-agents", + "link": true + }, + "node_modules/@cloudflare/sandbox-opencode-example": { + "resolved": "examples/opencode", + "link": true + }, + "node_modules/@cloudflare/sandbox-site": { + "resolved": "sites/sandbox", + "link": true + }, + "node_modules/@cloudflare/sandbox-typescript-validator-example": { + "resolved": "examples/typescript-validator", + "link": true + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.7.11", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.7.11.tgz", + "integrity": "sha512-se23f1D4PxKrMKOq+Stz+Yn7AJ9ITHcEecXo2Yjb+UgbUDCEBch1FXQC6hx6uT5fNA3kmX3mfzeZiUmpK1W9IQ==", + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": "^1.20251106.1" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/vite-plugin": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/@cloudflare/vite-plugin/-/vite-plugin-1.20.1.tgz", + "integrity": "sha512-hKe2ghXFAWG4s2c08LQZao5Ymt0HBC/XqrUINowHhru2ylnjGp3uuMnI/J1eKpkw1TBdR3weT/EvwT/XtS/b5A==", + "license": "MIT", + "dependencies": { + "@cloudflare/unenv-preset": "2.8.0", + "@remix-run/node-fetch-server": "^0.8.0", + "defu": "^6.1.4", + "get-port": "^7.1.0", + "miniflare": "4.20260107.0", + "picocolors": "^1.1.1", + "tinyglobby": "^0.2.12", + "unenv": "2.0.0-rc.24", + "wrangler": "4.58.0", + "ws": "8.18.0" + }, + "peerDependencies": { + "vite": "^6.1.0 || ^7.0.0", + "wrangler": "^4.58.0" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@cloudflare/kv-asset-handler": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.1.tgz", + "integrity": "sha512-Nu8ahitGFFJztxUml9oD/DLb7Z28C8cd8F46IVQ7y5Btz575pvMY8AqZsXkX7Gds29eCKdMgIHjIvzskHgPSFg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "mime": "^3.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@cloudflare/unenv-preset": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.8.0.tgz", + "integrity": "sha512-oIAu6EdQ4zJuPwwKr9odIEqd8AV96z1aqi3RBEA4iKaJ+Vd3fvuI6m5EDC7/QCv+oaPIhy1SkYBYxmD09N+oZg==", + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": "^1.20251202.0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", + "integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/android-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz", + "integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/android-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz", + "integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/android-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz", + "integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz", + "integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/darwin-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz", + "integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz", + "integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz", + "integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/linux-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz", + "integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/linux-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz", + "integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/linux-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz", + "integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/linux-loong64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz", + "integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz", + "integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz", + "integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz", + "integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/linux-s390x": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz", + "integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/linux-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz", + "integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz", + "integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz", + "integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz", + "integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz", + "integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz", + "integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/sunos-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz", + "integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/win32-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz", + "integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/win32-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz", + "integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/win32-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz", + "integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/esbuild": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz", + "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.0", + "@esbuild/android-arm": "0.27.0", + "@esbuild/android-arm64": "0.27.0", + "@esbuild/android-x64": "0.27.0", + "@esbuild/darwin-arm64": "0.27.0", + "@esbuild/darwin-x64": "0.27.0", + "@esbuild/freebsd-arm64": "0.27.0", + "@esbuild/freebsd-x64": "0.27.0", + "@esbuild/linux-arm": "0.27.0", + "@esbuild/linux-arm64": "0.27.0", + "@esbuild/linux-ia32": "0.27.0", + "@esbuild/linux-loong64": "0.27.0", + "@esbuild/linux-mips64el": "0.27.0", + "@esbuild/linux-ppc64": "0.27.0", + "@esbuild/linux-riscv64": "0.27.0", + "@esbuild/linux-s390x": "0.27.0", + "@esbuild/linux-x64": "0.27.0", + "@esbuild/netbsd-arm64": "0.27.0", + "@esbuild/netbsd-x64": "0.27.0", + "@esbuild/openbsd-arm64": "0.27.0", + "@esbuild/openbsd-x64": "0.27.0", + "@esbuild/openharmony-arm64": "0.27.0", + "@esbuild/sunos-x64": "0.27.0", + "@esbuild/win32-arm64": "0.27.0", + "@esbuild/win32-ia32": "0.27.0", + "@esbuild/win32-x64": "0.27.0" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/miniflare": { + "version": "4.20260107.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260107.0.tgz", + "integrity": "sha512-X93sXczqbBq9ixoM6jnesmdTqp+4baVC/aM/DuPpRS0LK0XtcqaO75qPzNEvDEzBAHxwMAWRIum/9hg32YB8iA==", + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "acorn": "8.14.0", + "acorn-walk": "8.3.2", + "exit-hook": "2.2.1", + "glob-to-regexp": "0.4.1", + "sharp": "^0.33.5", + "stoppable": "1.1.0", + "undici": "7.14.0", + "workerd": "1.20260107.1", + "ws": "8.18.0", + "youch": "4.1.0-beta.10", + "zod": "^3.25.76" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/miniflare/node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260107.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260107.1.tgz", + "integrity": "sha512-Srwe/IukVppkMU2qTndkFaKCmZBI7CnZoq4Y0U0gD/8158VGzMREHTqCii4IcCeHifwrtDqTWu8EcA1VBKI4mg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/miniflare/node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260107.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260107.1.tgz", + "integrity": "sha512-aAYwU7zXW+UZFh/a4vHP5cs1ulTOcDRLzwU9547yKad06RlZ6ioRm7ovjdYvdqdmbI8mPd99v4LN9gMmecazQw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/miniflare/node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260107.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260107.1.tgz", + "integrity": "sha512-Wh7xWtFOkk6WY3CXe3lSqZ1anMkFcwy+qOGIjtmvQ/3nCOaG34vKNwPIE9iwryPupqkSuDmEqkosI1UUnSTh1A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" } }, - "node_modules/@changesets/types": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@changesets/types/-/types-6.1.0.tgz", - "integrity": "sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA==", - "dev": true, - "license": "MIT" + "node_modules/@cloudflare/vite-plugin/node_modules/miniflare/node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260107.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260107.1.tgz", + "integrity": "sha512-NI0/5rdssdZZKYHxNG4umTmMzODByq86vSCEk8u4HQbGhRCQo7rV1eXn84ntSBdyWBzWdYGISCbeZMsgfIjSTg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } }, - "node_modules/@changesets/write": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@changesets/write/-/write-0.4.0.tgz", - "integrity": "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@changesets/types": "^6.1.0", - "fs-extra": "^7.0.1", - "human-id": "^4.1.1", - "prettier": "^2.7.1" + "node_modules/@cloudflare/vite-plugin/node_modules/miniflare/node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260107.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260107.1.tgz", + "integrity": "sha512-gmBMqs606Gd/IhBEBPSL/hJAqy2L8IyPUjKtoqd/Ccy7GQxbSc0rYlRkxbQ9YzmqnuhrTVYvXuLscyWrpmAJkw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" } }, - "node_modules/@changesets/write/node_modules/prettier": { - "version": "2.8.8", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", - "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", - "dev": true, - "license": "MIT", + "node_modules/@cloudflare/vite-plugin/node_modules/miniflare/node_modules/workerd": { + "version": "1.20260107.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260107.1.tgz", + "integrity": "sha512-4ylAQJDdJZdMAUl2SbJgTa77YHpa88l6qmhiuCLNactP933+rifs7I0w1DslhUIFgydArUX5dNLAZnZhT7Bh7g==", + "hasInstallScript": true, + "license": "Apache-2.0", "bin": { - "prettier": "bin-prettier.js" + "workerd": "bin/workerd" }, "engines": { - "node": ">=10.13.0" + "node": ">=16" }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260107.1", + "@cloudflare/workerd-darwin-arm64": "1.20260107.1", + "@cloudflare/workerd-linux-64": "1.20260107.1", + "@cloudflare/workerd-linux-arm64": "1.20260107.1", + "@cloudflare/workerd-windows-64": "1.20260107.1" } }, - "node_modules/@cloudflare/containers": { - "version": "0.0.30", - "resolved": "https://registry.npmjs.org/@cloudflare/containers/-/containers-0.0.30.tgz", - "integrity": "sha512-i148xBgmyn/pje82ZIyuTr/Ae0BT/YWwa1/GTJcw6DxEjUHAzZLaBCiX446U9OeuJ2rBh/L/9FIzxX5iYNt1AQ==", - "license": "ISC" - }, - "node_modules/@cloudflare/kv-asset-handler": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.0.tgz", - "integrity": "sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA==", + "node_modules/@cloudflare/vite-plugin/node_modules/wrangler": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.58.0.tgz", + "integrity": "sha512-Jm6EYtlt8iUcznOCPSMYC54DYkwrMNESzbH0Vh3GFHv/7XVw5gBC13YJAB+nWMRGJ+6B2dMzy/NVQS4ONL51Pw==", "license": "MIT OR Apache-2.0", "dependencies": { - "mime": "^3.0.0" + "@cloudflare/kv-asset-handler": "0.4.1", + "@cloudflare/unenv-preset": "2.8.0", + "blake3-wasm": "2.1.5", + "esbuild": "0.27.0", + "miniflare": "4.20260107.0", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260107.1" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20260107.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } } }, - "node_modules/@cloudflare/sandbox": { - "resolved": "packages/sandbox", - "link": true - }, - "node_modules/@cloudflare/sandbox-claude-code-example": { - "resolved": "examples/claude-code", - "link": true - }, - "node_modules/@cloudflare/sandbox-code-interpreter-example": { - "resolved": "examples/code-interpreter", - "link": true - }, - "node_modules/@cloudflare/sandbox-minimal-example": { - "resolved": "examples/minimal", - "link": true - }, - "node_modules/@cloudflare/sandbox-openai-agents-example": { - "resolved": "examples/openai-agents", - "link": true + "node_modules/@cloudflare/vite-plugin/node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260107.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260107.1.tgz", + "integrity": "sha512-Srwe/IukVppkMU2qTndkFaKCmZBI7CnZoq4Y0U0gD/8158VGzMREHTqCii4IcCeHifwrtDqTWu8EcA1VBKI4mg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } }, - "node_modules/@cloudflare/sandbox-opencode-example": { - "resolved": "examples/opencode", - "link": true + "node_modules/@cloudflare/vite-plugin/node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260107.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260107.1.tgz", + "integrity": "sha512-aAYwU7zXW+UZFh/a4vHP5cs1ulTOcDRLzwU9547yKad06RlZ6ioRm7ovjdYvdqdmbI8mPd99v4LN9gMmecazQw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } }, - "node_modules/@cloudflare/sandbox-site": { - "resolved": "sites/sandbox", - "link": true + "node_modules/@cloudflare/vite-plugin/node_modules/wrangler/node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260107.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260107.1.tgz", + "integrity": "sha512-Wh7xWtFOkk6WY3CXe3lSqZ1anMkFcwy+qOGIjtmvQ/3nCOaG34vKNwPIE9iwryPupqkSuDmEqkosI1UUnSTh1A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } }, - "node_modules/@cloudflare/sandbox-typescript-validator-example": { - "resolved": "examples/typescript-validator", - "link": true + "node_modules/@cloudflare/vite-plugin/node_modules/wrangler/node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260107.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260107.1.tgz", + "integrity": "sha512-NI0/5rdssdZZKYHxNG4umTmMzODByq86vSCEk8u4HQbGhRCQo7rV1eXn84ntSBdyWBzWdYGISCbeZMsgfIjSTg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } }, - "node_modules/@cloudflare/unenv-preset": { - "version": "2.7.11", - "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.7.11.tgz", - "integrity": "sha512-se23f1D4PxKrMKOq+Stz+Yn7AJ9ITHcEecXo2Yjb+UgbUDCEBch1FXQC6hx6uT5fNA3kmX3mfzeZiUmpK1W9IQ==", - "license": "MIT OR Apache-2.0", - "peerDependencies": { - "unenv": "2.0.0-rc.24", - "workerd": "^1.20251106.1" - }, - "peerDependenciesMeta": { - "workerd": { - "optional": true - } + "node_modules/@cloudflare/vite-plugin/node_modules/wrangler/node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260107.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260107.1.tgz", + "integrity": "sha512-gmBMqs606Gd/IhBEBPSL/hJAqy2L8IyPUjKtoqd/Ccy7GQxbSc0rYlRkxbQ9YzmqnuhrTVYvXuLscyWrpmAJkw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" } }, - "node_modules/@cloudflare/vite-plugin": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/@cloudflare/vite-plugin/-/vite-plugin-1.15.2.tgz", - "integrity": "sha512-SPMxsesbABOjzcAa4IzW+yM+fTIjx3GG1doh229Pg16FjSEZJhknyRpcld4gnaZioK3JKwG9FWdKsUhbplKY8w==", - "license": "MIT", - "dependencies": { - "@cloudflare/unenv-preset": "2.7.11", - "@remix-run/node-fetch-server": "^0.8.0", - "get-port": "^7.1.0", - "miniflare": "4.20251118.1", - "picocolors": "^1.1.1", - "tinyglobby": "^0.2.12", - "unenv": "2.0.0-rc.24", - "wrangler": "4.50.0", - "ws": "8.18.0" + "node_modules/@cloudflare/vite-plugin/node_modules/wrangler/node_modules/workerd": { + "version": "1.20260107.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260107.1.tgz", + "integrity": "sha512-4ylAQJDdJZdMAUl2SbJgTa77YHpa88l6qmhiuCLNactP933+rifs7I0w1DslhUIFgydArUX5dNLAZnZhT7Bh7g==", + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" }, - "peerDependencies": { - "vite": "^6.1.0 || ^7.0.0", - "wrangler": "^4.50.0" + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260107.1", + "@cloudflare/workerd-darwin-arm64": "1.20260107.1", + "@cloudflare/workerd-linux-64": "1.20260107.1", + "@cloudflare/workerd-linux-arm64": "1.20260107.1", + "@cloudflare/workerd-windows-64": "1.20260107.1" } }, "node_modules/@cloudflare/vite-plugin/node_modules/ws": { @@ -1520,9 +2293,9 @@ } }, "node_modules/@cloudflare/workers-types": { - "version": "4.20251126.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20251126.0.tgz", - "integrity": "sha512-DSeI1Q7JYmh5/D/tw5eZCjrKY34v69rwj63hHt60nSQW5QLwWCbj/lLtNz9f2EPa+JCACwpLXHgCXfzJ29x66w==", + "version": "4.20260108.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260108.0.tgz", + "integrity": "sha512-0SuzZ7SeMB35X0wL2rhsEQG1dmfAGY8N8z7UwrkFb6hxerxwXP4QuIzcF8HtCJTRTjChmarxV+HQC+ADB4UK1A==", "devOptional": true, "license": "MIT OR Apache-2.0" }, @@ -4633,6 +5406,33 @@ "integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==", "license": "MIT" }, + "node_modules/@xterm/addon-fit": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", + "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/addon-web-links": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", + "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", + "license": "MIT" + }, + "node_modules/@xterm/addon-webgl": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz", + "integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", + "license": "MIT" + }, "node_modules/acorn": { "version": "8.14.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", @@ -9413,6 +10213,12 @@ "node": ">=8" } }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "license": "MIT" + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -12824,12 +13630,6 @@ "@esbuild/win32-x64": "0.25.4" } }, - "node_modules/wrangler/node_modules/path-to-regexp": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", - "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", - "license": "MIT" - }, "node_modules/wrap-ansi": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", @@ -13181,9 +13981,20 @@ "@repo/typescript-config": "*", "@types/acorn": "^4.0.6", "@types/bun": "^1.3.3", + "bun-types": "^1.3.5", "typescript": "^5.9.3" } }, + "packages/sandbox-container/node_modules/bun-types": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.5.tgz", + "integrity": "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "packages/shared": { "name": "@repo/shared", "version": "0.0.0", diff --git a/packages/sandbox-container/package.json b/packages/sandbox-container/package.json index a43dea91..f5b46a7a 100644 --- a/packages/sandbox-container/package.json +++ b/packages/sandbox-container/package.json @@ -20,6 +20,7 @@ "@repo/typescript-config": "*", "@types/acorn": "^4.0.6", "@types/bun": "^1.3.3", + "bun-types": "^1.3.5", "typescript": "^5.9.3" } } diff --git a/packages/sandbox-container/src/core/container.ts b/packages/sandbox-container/src/core/container.ts index db3a6392..f57f19b2 100644 --- a/packages/sandbox-container/src/core/container.ts +++ b/packages/sandbox-container/src/core/container.ts @@ -7,7 +7,9 @@ import { InterpreterHandler } from '../handlers/interpreter-handler'; import { MiscHandler } from '../handlers/misc-handler'; import { PortHandler } from '../handlers/port-handler'; import { ProcessHandler } from '../handlers/process-handler'; +import { PtyHandler } from '../handlers/pty-handler'; import { SessionHandler } from '../handlers/session-handler'; +import { PtyManager } from '../managers/pty-manager'; import { CorsMiddleware } from '../middleware/cors'; import { LoggingMiddleware } from '../middleware/logging'; import { SecurityServiceAdapter } from '../security/security-adapter'; @@ -28,6 +30,9 @@ export interface Dependencies { gitService: GitService; interpreterService: InterpreterService; + // Managers + ptyManager: PtyManager; + // Infrastructure logger: Logger; security: SecurityService; @@ -40,6 +45,7 @@ export interface Dependencies { gitHandler: GitHandler; interpreterHandler: InterpreterHandler; sessionHandler: SessionHandler; + ptyHandler: PtyHandler; miscHandler: MiscHandler; // Middleware @@ -113,6 +119,9 @@ export class Container { ); const interpreterService = new InterpreterService(logger); + // Initialize managers + const ptyManager = new PtyManager(logger); + // Initialize handlers const sessionHandler = new SessionHandler(sessionManager, logger); const executeHandler = new ExecuteHandler(processService, logger); @@ -124,6 +133,7 @@ export class Container { interpreterService, logger ); + const ptyHandler = new PtyHandler(ptyManager, logger); const miscHandler = new MiscHandler(logger); // Initialize middleware @@ -139,6 +149,9 @@ export class Container { gitService, interpreterService, + // Managers + ptyManager, + // Infrastructure logger, security, @@ -151,6 +164,7 @@ export class Container { gitHandler, interpreterHandler, sessionHandler, + ptyHandler, miscHandler, // Middleware diff --git a/packages/sandbox-container/src/core/router.ts b/packages/sandbox-container/src/core/router.ts index 1c9dc5d4..ea141f1c 100644 --- a/packages/sandbox-container/src/core/router.ts +++ b/packages/sandbox-container/src/core/router.ts @@ -25,6 +25,10 @@ export class Router { * Register a route with optional middleware */ register(definition: RouteDefinition): void { + this.logger.debug('Registering route', { + method: definition.method, + path: definition.path + }); this.routes.push(definition); } diff --git a/packages/sandbox-container/src/handlers/pty-handler.ts b/packages/sandbox-container/src/handlers/pty-handler.ts new file mode 100644 index 00000000..c02efad7 --- /dev/null +++ b/packages/sandbox-container/src/handlers/pty-handler.ts @@ -0,0 +1,469 @@ +import type { + CreatePtyOptions, + Logger, + PtyCreateResult, + PtyGetResult, + PtyInputRequest, + PtyInputResult, + PtyKillResult, + PtyListResult, + PtyResizeRequest, + PtyResizeResult +} from '@repo/shared'; +import { ErrorCode } from '@repo/shared/errors'; + +import type { RequestContext } from '../core/types'; +import type { PtyManager } from '../managers/pty-manager'; +import { BaseHandler } from './base-handler'; + +export class PtyHandler extends BaseHandler { + constructor( + private ptyManager: PtyManager, + logger: Logger + ) { + super(logger); + } + + async handle(request: Request, context: RequestContext): Promise { + const url = new URL(request.url); + const pathname = url.pathname; + + // POST /api/pty - Create new PTY + if (pathname === '/api/pty' && request.method === 'POST') { + return this.handleCreate(request, context); + } + + // GET /api/pty - List all PTYs + if (pathname === '/api/pty' && request.method === 'GET') { + return this.handleList(request, context); + } + + // Routes with PTY ID + if (pathname.startsWith('/api/pty/')) { + const segments = pathname.split('/'); + const ptyId = segments[3]; + const action = segments[4]; + + if (!ptyId) { + return this.createErrorResponse( + { message: 'PTY ID required', code: ErrorCode.VALIDATION_FAILED }, + context + ); + } + + // GET /api/pty/:id - Get PTY info + if (!action && request.method === 'GET') { + return this.handleGet(request, context, ptyId); + } + + // DELETE /api/pty/:id - Kill PTY + if (!action && request.method === 'DELETE') { + return this.handleKill(request, context, ptyId); + } + + // POST /api/pty/:id/input - Send input to PTY + if (action === 'input' && request.method === 'POST') { + return this.handleInput(request, context, ptyId); + } + + // POST /api/pty/:id/resize - Resize PTY + if (action === 'resize' && request.method === 'POST') { + return this.handleResize(request, context, ptyId); + } + + // GET /api/pty/:id/stream - SSE output stream + if (action === 'stream' && request.method === 'GET') { + return this.handleStream(request, context, ptyId); + } + } + + // POST /api/pty/attach/:sessionId - Attach PTY to existing session + if (pathname.startsWith('/api/pty/attach/') && request.method === 'POST') { + const sessionId = pathname.split('/')[4]; + if (!sessionId) { + return this.createErrorResponse( + { message: 'Session ID required', code: ErrorCode.VALIDATION_FAILED }, + context + ); + } + return this.handleAttach(request, context, sessionId); + } + + return this.createErrorResponse( + { message: 'Invalid PTY endpoint', code: ErrorCode.UNKNOWN_ERROR }, + context + ); + } + + private async handleCreate( + request: Request, + context: RequestContext + ): Promise { + const body = await this.parseRequestBody(request); + const ptySession = this.ptyManager.create(body); + + const response: PtyCreateResult = { + success: true, + pty: { + id: ptySession.id, + cols: ptySession.cols, + rows: ptySession.rows, + command: ptySession.command, + cwd: ptySession.cwd, + createdAt: ptySession.createdAt.toISOString(), + state: ptySession.state, + exitCode: ptySession.exitCode + }, + timestamp: new Date().toISOString() + }; + + return this.createTypedResponse(response, context); + } + + private async handleList( + _request: Request, + context: RequestContext + ): Promise { + const ptys = this.ptyManager.list(); + + const response: PtyListResult = { + success: true, + ptys, + timestamp: new Date().toISOString() + }; + + return this.createTypedResponse(response, context); + } + + private async handleGet( + _request: Request, + context: RequestContext, + ptyId: string + ): Promise { + const ptySession = this.ptyManager.get(ptyId); + + if (!ptySession) { + return this.createErrorResponse( + { message: 'PTY not found', code: ErrorCode.PTY_NOT_FOUND }, + context + ); + } + + const response: PtyGetResult = { + success: true, + pty: { + id: ptySession.id, + cols: ptySession.cols, + rows: ptySession.rows, + command: ptySession.command, + cwd: ptySession.cwd, + createdAt: ptySession.createdAt.toISOString(), + state: ptySession.state, + exitCode: ptySession.exitCode + }, + timestamp: new Date().toISOString() + }; + + return this.createTypedResponse(response, context); + } + + private async handleKill( + request: Request, + context: RequestContext, + ptyId: string + ): Promise { + const session = this.ptyManager.get(ptyId); + + if (!session) { + return this.createErrorResponse( + { message: 'PTY not found', code: ErrorCode.PTY_NOT_FOUND }, + context + ); + } + + // Body is optional for DELETE - only parse if content exists + let signal: string | undefined; + const contentLength = request.headers.get('content-length'); + if (contentLength && parseInt(contentLength, 10) > 0) { + const body = await this.parseRequestBody<{ signal?: string }>(request); + signal = body.signal; + } + + const result = this.ptyManager.kill(ptyId, signal); + + if (!result.success) { + return this.createErrorResponse( + { + message: result.error ?? 'PTY kill failed', + code: ErrorCode.PTY_OPERATION_ERROR + }, + context + ); + } + + const response: PtyKillResult = { + success: true, + ptyId, + timestamp: new Date().toISOString() + }; + + return this.createTypedResponse(response, context); + } + + private async handleInput( + request: Request, + context: RequestContext, + ptyId: string + ): Promise { + const session = this.ptyManager.get(ptyId); + + if (!session) { + return this.createErrorResponse( + { message: 'PTY not found', code: ErrorCode.PTY_NOT_FOUND }, + context + ); + } + + const body = await this.parseRequestBody(request); + + if (!body.data) { + return this.createErrorResponse( + { message: 'Input data required', code: ErrorCode.VALIDATION_FAILED }, + context + ); + } + + const result = this.ptyManager.write(ptyId, body.data); + + if (!result.success) { + return this.createErrorResponse( + { + message: result.error ?? 'PTY input failed', + code: ErrorCode.PTY_OPERATION_ERROR + }, + context + ); + } + + const response: PtyInputResult = { + success: true, + ptyId, + timestamp: new Date().toISOString() + }; + + return this.createTypedResponse(response, context); + } + + private async handleResize( + request: Request, + context: RequestContext, + ptyId: string + ): Promise { + const session = this.ptyManager.get(ptyId); + + if (!session) { + return this.createErrorResponse( + { message: 'PTY not found', code: ErrorCode.PTY_NOT_FOUND }, + context + ); + } + + const body = await this.parseRequestBody(request); + + if (body.cols === undefined || body.rows === undefined) { + return this.createErrorResponse( + { + message: 'Both cols and rows are required', + code: ErrorCode.VALIDATION_FAILED + }, + context + ); + } + + const result = this.ptyManager.resize(ptyId, body.cols, body.rows); + + if (!result.success) { + return this.createErrorResponse( + { + message: result.error ?? 'PTY resize failed', + code: ErrorCode.PTY_OPERATION_ERROR + }, + context + ); + } + + const response: PtyResizeResult = { + success: true, + ptyId, + cols: body.cols, + rows: body.rows, + timestamp: new Date().toISOString() + }; + + return this.createTypedResponse(response, context); + } + + private async handleAttach( + request: Request, + context: RequestContext, + sessionId: string + ): Promise { + // Check if session already has a PTY attached + const existingPtys = this.ptyManager.list(); + const existingPty = existingPtys.find((p) => p.sessionId === sessionId); + + if (existingPty && existingPty.state === 'running') { + return this.createErrorResponse( + { + message: `Session already has active PTY: ${existingPty.id}`, + code: ErrorCode.PTY_ALREADY_ATTACHED + }, + context + ); + } + + // Create a PTY attached to the session + const body = await this.parseRequestBody(request); + const ptySession = this.ptyManager.create({ + ...body, + sessionId + }); + + const response: PtyCreateResult = { + success: true, + pty: { + id: ptySession.id, + cols: ptySession.cols, + rows: ptySession.rows, + command: ptySession.command, + cwd: ptySession.cwd, + createdAt: ptySession.createdAt.toISOString(), + state: ptySession.state, + exitCode: ptySession.exitCode, + sessionId + }, + timestamp: new Date().toISOString() + }; + + return this.createTypedResponse(response, context); + } + + private async handleStream( + _request: Request, + context: RequestContext, + ptyId: string + ): Promise { + const session = this.ptyManager.get(ptyId); + + if (!session) { + return this.createErrorResponse( + { message: 'PTY not found', code: ErrorCode.PTY_NOT_FOUND }, + context + ); + } + + // Track cleanup functions for proper unsubscription + let unsubData: (() => void) | null = null; + let unsubExit: (() => void) | null = null; + + // Capture logger for use in stream callbacks + const logger = this.logger; + + const stream = new ReadableStream({ + start: (controller) => { + const encoder = new TextEncoder(); + + // Send initial info + const info = `data: ${JSON.stringify({ + type: 'pty_info', + ptyId: session.id, + cols: session.cols, + rows: session.rows, + timestamp: new Date().toISOString() + })}\n\n`; + controller.enqueue(encoder.encode(info)); + + // Listen for data + unsubData = this.ptyManager.onData(ptyId, (data) => { + try { + const event = `data: ${JSON.stringify({ + type: 'pty_data', + data, + timestamp: new Date().toISOString() + })}\n\n`; + controller.enqueue(encoder.encode(event)); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + // TypeError with 'closed' or 'errored' indicates client disconnect (expected) + // Other errors may indicate infrastructure issues + const isExpectedDisconnect = + error instanceof TypeError && + (errorMessage.includes('closed') || + errorMessage.includes('errored')); + if (isExpectedDisconnect) { + logger.debug('SSE stream enqueue skipped (client disconnected)', { + ptyId + }); + } else { + logger.error( + 'SSE stream enqueue failed unexpectedly', + error instanceof Error ? error : new Error(errorMessage), + { ptyId } + ); + } + } + }); + + // Listen for exit + unsubExit = this.ptyManager.onExit(ptyId, (exitCode) => { + try { + const event = `data: ${JSON.stringify({ + type: 'pty_exit', + exitCode, + timestamp: new Date().toISOString() + })}\n\n`; + controller.enqueue(encoder.encode(event)); + controller.close(); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + // TypeError with 'closed' or 'errored' indicates client disconnect (expected) + // Other errors may indicate infrastructure issues + const isExpectedDisconnect = + error instanceof TypeError && + (errorMessage.includes('closed') || + errorMessage.includes('errored')); + if (isExpectedDisconnect) { + logger.debug('SSE stream close skipped (client disconnected)', { + ptyId, + exitCode + }); + } else { + logger.error( + 'SSE stream close failed unexpectedly', + error instanceof Error ? error : new Error(errorMessage), + { ptyId, exitCode } + ); + } + } + }); + }, + cancel: () => { + // Clean up listeners when stream is cancelled + unsubData?.(); + unsubExit?.(); + } + }); + + return new Response(stream, { + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + ...context.corsHeaders + } + }); + } +} diff --git a/packages/sandbox-container/src/handlers/ws-adapter.ts b/packages/sandbox-container/src/handlers/ws-adapter.ts index 23d74bfb..5c2f0ace 100644 --- a/packages/sandbox-container/src/handlers/ws-adapter.ts +++ b/packages/sandbox-container/src/handlers/ws-adapter.ts @@ -8,6 +8,8 @@ import type { Logger } from '@repo/shared'; import { + isWSPtyInput, + isWSPtyResize, isWSRequest, type WSError, type WSRequest, @@ -17,6 +19,7 @@ import { } from '@repo/shared'; import type { ServerWebSocket } from 'bun'; import type { Router } from '../core/router'; +import type { PtyManager } from '../managers/pty-manager'; /** Container server port - must match SERVER_PORT in server.ts */ const SERVER_PORT = 3000; @@ -37,10 +40,13 @@ export interface WSData { */ export class WebSocketAdapter { private router: Router; + private ptyManager: PtyManager; private logger: Logger; + private connectionCleanups = new Map void>>(); - constructor(router: Router, logger: Logger) { + constructor(router: Router, ptyManager: PtyManager, logger: Logger) { this.router = router; + this.ptyManager = ptyManager; this.logger = logger.child({ component: 'container' }); } @@ -57,8 +63,23 @@ export class WebSocketAdapter { * Handle WebSocket connection close */ onClose(ws: ServerWebSocket, code: number, reason: string): void { + const connectionId = ws.data.connectionId; + + // Clean up any PTY listeners registered for this connection + const cleanups = this.connectionCleanups.get(connectionId); + if (cleanups) { + this.logger.debug('Cleaning up PTY listeners for closed connection', { + connectionId, + listenerCount: cleanups.length + }); + for (const cleanup of cleanups) { + cleanup(); + } + this.connectionCleanups.delete(connectionId); + } + this.logger.debug('WebSocket connection closed', { - connectionId: ws.data.connectionId, + connectionId, code, reason }); @@ -82,6 +103,64 @@ export class WebSocketAdapter { return; } + // Handle PTY input messages + if (isWSPtyInput(parsed)) { + const result = this.ptyManager.write(parsed.ptyId, parsed.data); + if (!result.success) { + const errorSent = this.sendError( + ws, + parsed.ptyId, + 'PTY_ERROR', + result.error ?? 'PTY write failed', + 400 + ); + if (!errorSent) { + this.logger.error( + 'PTY write failed AND error notification failed - client will not be notified', + undefined, + { + ptyId: parsed.ptyId, + error: result.error, + connectionId: ws.data.connectionId + } + ); + } + } + return; + } + + // Handle PTY resize messages + if (isWSPtyResize(parsed)) { + const result = this.ptyManager.resize( + parsed.ptyId, + parsed.cols, + parsed.rows + ); + if (!result.success) { + const errorSent = this.sendError( + ws, + parsed.ptyId, + 'PTY_ERROR', + result.error ?? 'PTY resize failed', + 400 + ); + if (!errorSent) { + this.logger.error( + 'PTY resize failed AND error notification failed - client will not be notified', + undefined, + { + ptyId: parsed.ptyId, + cols: parsed.cols, + rows: parsed.rows, + error: result.error, + connectionId: ws.data.connectionId + } + ); + } + } + return; + } + if (!isWSRequest(parsed)) { this.sendError( ws, @@ -164,19 +243,65 @@ export class WebSocketAdapter { // Handle SSE streaming response await this.handleStreamingResponse(ws, request.id, httpResponse); } else { - // Handle regular response - await this.handleRegularResponse(ws, request.id, httpResponse); + // Handle regular response and check for PTY operations + const body = await this.handleRegularResponse( + ws, + request.id, + httpResponse + ); + + // Register PTY listener for successful PTY create/get operations + // This enables real-time PTY output streaming over WebSocket + if (httpResponse.status === 200 && body) { + const ptyId = this.extractPtyId(request.path, request.method, body); + if (ptyId) { + this.registerPtyListener(ws, ptyId); + this.logger.debug('Registered PTY listener for WebSocket streaming', { + ptyId, + connectionId: ws.data.connectionId + }); + } + } } } + /** + * Extract PTY ID from successful PTY create/get responses + */ + private extractPtyId( + path: string, + method: string, + body: unknown + ): string | null { + // PTY create: POST /api/pty + if (path === '/api/pty' && method === 'POST') { + const response = body as { success?: boolean; pty?: { id?: string } }; + if (response?.success && response?.pty?.id) { + return response.pty.id; + } + } + + // PTY get: GET /api/pty/:id + const getPtyMatch = path.match(/^\/api\/pty\/([^/]+)$/); + if (getPtyMatch && method === 'GET') { + const response = body as { success?: boolean; pty?: { id?: string } }; + if (response?.success && response?.pty?.id) { + return response.pty.id; + } + } + + return null; + } + /** * Handle a regular (non-streaming) HTTP response + * Returns the parsed body for further processing (e.g., PTY listener registration) */ private async handleRegularResponse( ws: ServerWebSocket, requestId: string, response: Response - ): Promise { + ): Promise { let body: unknown; try { @@ -195,6 +320,7 @@ export class WebSocketAdapter { }; this.send(ws, wsResponse); + return body; } /** @@ -345,6 +471,7 @@ export class WebSocketAdapter { /** * Send an error message over WebSocket + * @returns true if send succeeded, false if it failed */ private sendError( ws: ServerWebSocket, @@ -352,7 +479,7 @@ export class WebSocketAdapter { code: string, message: string, status: number - ): void { + ): boolean { const error: WSError = { type: 'error', id: requestId, @@ -360,7 +487,76 @@ export class WebSocketAdapter { message, status }; - this.send(ws, error); + return this.send(ws, error); + } + + /** + * Register PTY output listener for a WebSocket connection + * Returns cleanup function to unsubscribe from PTY events + * + * Auto-unsubscribes when send fails to prevent resource leaks + * from repeatedly attempting to send to a dead connection. + * Also tracked per-connection for cleanup when connection closes. + */ + registerPtyListener(ws: ServerWebSocket, ptyId: string): () => void { + const connectionId = ws.data.connectionId; + let unsubData: (() => void) | null = null; + let unsubExit: (() => void) | null = null; + let cleanedUp = false; + + const cleanup = () => { + if (cleanedUp) return; + cleanedUp = true; + + unsubData?.(); + unsubExit?.(); + unsubData = null; + unsubExit = null; + + // Remove from connection cleanups to prevent double-cleanup + const cleanups = this.connectionCleanups.get(connectionId); + if (cleanups) { + const index = cleanups.indexOf(cleanup); + if (index !== -1) { + cleanups.splice(index, 1); + } + if (cleanups.length === 0) { + this.connectionCleanups.delete(connectionId); + } + } + }; + + // Track cleanup for this connection + if (!this.connectionCleanups.has(connectionId)) { + this.connectionCleanups.set(connectionId, []); + } + this.connectionCleanups.get(connectionId)!.push(cleanup); + + unsubData = this.ptyManager.onData(ptyId, (data) => { + const chunk: WSStreamChunk = { + type: 'stream', + id: ptyId, + event: 'pty_data', + data + }; + if (!this.send(ws, chunk)) { + cleanup(); // Send failed, stop trying + } + }); + + unsubExit = this.ptyManager.onExit(ptyId, (exitCode) => { + const chunk: WSStreamChunk = { + type: 'stream', + id: ptyId, + event: 'pty_exit', + data: JSON.stringify({ exitCode }) + }; + if (!this.send(ws, chunk)) { + cleanup(); // Send failed, stop trying + } + }); + + return cleanup; } } diff --git a/packages/sandbox-container/src/managers/pty-manager.ts b/packages/sandbox-container/src/managers/pty-manager.ts new file mode 100644 index 00000000..7095e3dc --- /dev/null +++ b/packages/sandbox-container/src/managers/pty-manager.ts @@ -0,0 +1,368 @@ +import { + type CreatePtyOptions, + getPtyExitInfo, + type Logger, + type PtyExitInfo, + type PtyInfo, + type PtyState +} from '@repo/shared'; + +/** + * Minimal interface for Bun.Terminal (introduced in Bun v1.3.5+) + * Defined locally since it's only used in the container runtime. + * @types/bun doesn't include this yet, so we define it here. + */ +interface BunTerminal { + write(data: string): void; + resize(cols: number, rows: number): void; +} + +interface BunTerminalOptions { + cols: number; + rows: number; + data: (terminal: BunTerminal, data: Uint8Array) => void; +} + +type BunTerminalConstructor = new (options: BunTerminalOptions) => BunTerminal; + +export interface PtySession { + id: string; + terminal: BunTerminal; + process: ReturnType; + cols: number; + rows: number; + command: string[]; + cwd: string; + env: Record; + state: PtyState; + exitCode?: number; + exitInfo?: PtyExitInfo; + dataListeners: Set<(data: string) => void>; + exitListeners: Set<(code: number) => void>; + createdAt: Date; + sessionId?: string; +} + +export class PtyManager { + private sessions = new Map(); + + constructor(private logger: Logger) {} + + /** Maximum terminal dimensions (matches Daytona's limits) */ + private static readonly MAX_TERMINAL_SIZE = 1000; + + create(options: CreatePtyOptions): PtySession { + const id = this.generateId(); + const cols = options.cols ?? 80; + const rows = options.rows ?? 24; + const command = options.command ?? ['/bin/bash']; + const cwd = options.cwd ?? '/home/user'; + const env = options.env ?? {}; + + // Validate terminal dimensions + if (cols > PtyManager.MAX_TERMINAL_SIZE || cols < 1) { + throw new Error( + `Invalid cols: ${cols}. Must be between 1 and ${PtyManager.MAX_TERMINAL_SIZE}` + ); + } + if (rows > PtyManager.MAX_TERMINAL_SIZE || rows < 1) { + throw new Error( + `Invalid rows: ${rows}. Must be between 1 and ${PtyManager.MAX_TERMINAL_SIZE}` + ); + } + + const dataListeners = new Set<(data: string) => void>(); + const exitListeners = new Set<(code: number) => void>(); + + // Check if Bun.Terminal is available (introduced in Bun v1.3.5+) + const BunTerminalClass = (Bun as { Terminal?: BunTerminalConstructor }) + .Terminal; + if (!BunTerminalClass) { + throw new Error( + 'Bun.Terminal is not available. Requires Bun v1.3.5 or higher.' + ); + } + + // Capture logger for use in callbacks + const logger = this.logger; + + const terminal = new BunTerminalClass({ + cols, + rows, + data: (_term: BunTerminal, data: Uint8Array) => { + const text = new TextDecoder().decode(data); + for (const cb of dataListeners) { + try { + cb(text); + } catch (error) { + // Log error so users can debug their onData handlers + logger.error( + 'PTY data callback error - check your onData handler', + error instanceof Error ? error : new Error(String(error)), + { ptyId: id } + ); + } + } + } + }); + + // Type assertion needed until @types/bun includes Terminal API (introduced in v1.3.5) + const proc = Bun.spawn(command, { + terminal, + cwd, + env: { TERM: 'xterm-256color', ...process.env, ...env } + } as Parameters[1]); + + const session: PtySession = { + id, + terminal, + process: proc, + cols, + rows, + command, + cwd, + env, + state: 'running', + dataListeners, + exitListeners, + createdAt: new Date(), + sessionId: options.sessionId + }; + + // Track exit + proc.exited + .then((code) => { + session.state = 'exited'; + session.exitCode = code; + session.exitInfo = getPtyExitInfo(code); + + for (const cb of exitListeners) { + try { + cb(code); + } catch (error) { + // Log error so users can debug their onExit handlers + logger.error( + 'PTY exit callback error - check your onExit handler', + error instanceof Error ? error : new Error(String(error)), + { ptyId: id, exitCode: code } + ); + } + } + + // Clear listeners to prevent memory leaks + session.dataListeners.clear(); + session.exitListeners.clear(); + + this.logger.debug('PTY exited', { + ptyId: id, + exitCode: code, + exitInfo: session.exitInfo + }); + }) + .catch((error) => { + session.state = 'exited'; + session.exitCode = 1; + session.exitInfo = { + exitCode: 1, + reason: error instanceof Error ? error.message : 'Process error' + }; + + // Clear listeners to prevent memory leaks + session.dataListeners.clear(); + session.exitListeners.clear(); + + this.logger.error( + 'PTY process error', + error instanceof Error ? error : undefined, + { ptyId: id, exitInfo: session.exitInfo } + ); + }); + + this.sessions.set(id, session); + + this.logger.info('PTY created', { ptyId: id, command, cols, rows }); + + return session; + } + + get(id: string): PtySession | null { + return this.sessions.get(id) ?? null; + } + + list(): PtyInfo[] { + return Array.from(this.sessions.values()).map((s) => this.toInfo(s)); + } + + write(id: string, data: string): { success: boolean; error?: string } { + const session = this.sessions.get(id); + if (!session) { + this.logger.warn('Write to unknown PTY', { ptyId: id }); + return { success: false, error: 'PTY not found' }; + } + if (session.state !== 'running') { + this.logger.warn('Write to exited PTY', { ptyId: id }); + return { success: false, error: 'PTY has exited' }; + } + try { + // Write data directly to the terminal. + // The PTY's line discipline should handle control characters: + // - Ctrl+C (0x03) -> SIGINT to foreground process group + // - Ctrl+Z (0x1A) -> SIGTSTP to foreground process group + // - Ctrl+\ (0x1C) -> SIGQUIT to foreground process group + session.terminal.write(data); + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + this.logger.error( + 'PTY write failed', + error instanceof Error ? error : undefined, + { ptyId: id } + ); + return { success: false, error: message }; + } + } + + resize( + id: string, + cols: number, + rows: number + ): { success: boolean; error?: string } { + const session = this.sessions.get(id); + if (!session) { + this.logger.warn('Resize unknown PTY', { ptyId: id }); + return { success: false, error: 'PTY not found' }; + } + if (session.state !== 'running') { + this.logger.warn('Resize exited PTY', { ptyId: id }); + return { success: false, error: 'PTY has exited' }; + } + // Validate dimensions + if ( + cols > PtyManager.MAX_TERMINAL_SIZE || + cols < 1 || + rows > PtyManager.MAX_TERMINAL_SIZE || + rows < 1 + ) { + this.logger.warn('Invalid resize dimensions', { ptyId: id, cols, rows }); + return { + success: false, + error: `Invalid dimensions. Must be between 1 and ${PtyManager.MAX_TERMINAL_SIZE}` + }; + } + try { + session.terminal.resize(cols, rows); + session.cols = cols; + session.rows = rows; + this.logger.debug('PTY resized', { ptyId: id, cols, rows }); + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + this.logger.error( + 'PTY resize failed', + error instanceof Error ? error : undefined, + { ptyId: id, cols, rows } + ); + return { success: false, error: message }; + } + } + + kill(id: string, signal?: string): { success: boolean; error?: string } { + const session = this.sessions.get(id); + if (!session) { + this.logger.warn('Kill unknown PTY', { ptyId: id }); + return { success: false, error: 'PTY not found' }; + } + + try { + session.process.kill(signal === 'SIGKILL' ? 9 : 15); + this.logger.info('PTY killed', { ptyId: id, signal }); + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + this.logger.error( + 'Failed to kill PTY', + error instanceof Error ? error : undefined, + { ptyId: id, signal } + ); + return { success: false, error: message }; + } + } + + killAll(): void { + for (const [id] of this.sessions) { + this.kill(id); + } + } + + onData(id: string, callback: (data: string) => void): () => void { + const session = this.sessions.get(id); + if (!session) { + this.logger.warn( + 'Registering onData listener for unknown PTY - callback will never fire', + { + ptyId: id + } + ); + return () => {}; + } + session.dataListeners.add(callback); + return () => session.dataListeners.delete(callback); + } + + onExit(id: string, callback: (code: number) => void): () => void { + const session = this.sessions.get(id); + if (!session) { + this.logger.warn( + 'Registering onExit listener for unknown PTY - callback will never fire', + { + ptyId: id + } + ); + return () => {}; + } + + // If already exited, call immediately + if (session.state === 'exited' && session.exitCode !== undefined) { + try { + callback(session.exitCode); + } catch (error) { + this.logger.error( + 'PTY onExit callback error - check your onExit handler', + error instanceof Error ? error : new Error(String(error)), + { ptyId: id, exitCode: session.exitCode } + ); + } + return () => {}; + } + + session.exitListeners.add(callback); + return () => session.exitListeners.delete(callback); + } + + cleanup(id: string): void { + const session = this.sessions.get(id); + if (!session) return; + + this.sessions.delete(id); + this.logger.debug('PTY cleaned up', { ptyId: id }); + } + + private generateId(): string { + return `pty_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`; + } + + private toInfo(session: PtySession): PtyInfo { + return { + id: session.id, + cols: session.cols, + rows: session.rows, + command: session.command, + cwd: session.cwd, + createdAt: session.createdAt.toISOString(), + state: session.state, + exitCode: session.exitCode, + exitInfo: session.exitInfo, + sessionId: session.sessionId + }; + } +} diff --git a/packages/sandbox-container/src/routes/setup.ts b/packages/sandbox-container/src/routes/setup.ts index 16e2b999..59f864bd 100644 --- a/packages/sandbox-container/src/routes/setup.ts +++ b/packages/sandbox-container/src/routes/setup.ts @@ -196,6 +196,63 @@ export function setupRoutes(router: Router, container: Container): void { middleware: [container.get('loggingMiddleware')] }); + // PTY management routes + router.register({ + method: 'POST', + path: '/api/pty', + handler: async (req, ctx) => container.get('ptyHandler').handle(req, ctx), + middleware: [container.get('loggingMiddleware')] + }); + + router.register({ + method: 'GET', + path: '/api/pty', + handler: async (req, ctx) => container.get('ptyHandler').handle(req, ctx), + middleware: [container.get('loggingMiddleware')] + }); + + router.register({ + method: 'GET', + path: '/api/pty/{id}', + handler: async (req, ctx) => container.get('ptyHandler').handle(req, ctx), + middleware: [container.get('loggingMiddleware')] + }); + + router.register({ + method: 'DELETE', + path: '/api/pty/{id}', + handler: async (req, ctx) => container.get('ptyHandler').handle(req, ctx), + middleware: [container.get('loggingMiddleware')] + }); + + router.register({ + method: 'POST', + path: '/api/pty/{id}/input', + handler: async (req, ctx) => container.get('ptyHandler').handle(req, ctx), + middleware: [container.get('loggingMiddleware')] + }); + + router.register({ + method: 'POST', + path: '/api/pty/{id}/resize', + handler: async (req, ctx) => container.get('ptyHandler').handle(req, ctx), + middleware: [container.get('loggingMiddleware')] + }); + + router.register({ + method: 'GET', + path: '/api/pty/{id}/stream', + handler: async (req, ctx) => container.get('ptyHandler').handle(req, ctx), + middleware: [container.get('loggingMiddleware')] + }); + + router.register({ + method: 'POST', + path: '/api/pty/attach/{sessionId}', + handler: async (req, ctx) => container.get('ptyHandler').handle(req, ctx), + middleware: [container.get('loggingMiddleware')] + }); + // Git operations router.register({ method: 'POST', diff --git a/packages/sandbox-container/src/server.ts b/packages/sandbox-container/src/server.ts index 3dbababb..e71fc66c 100644 --- a/packages/sandbox-container/src/server.ts +++ b/packages/sandbox-container/src/server.ts @@ -33,7 +33,8 @@ async function createApplication(): Promise<{ setupRoutes(router, container); // Create WebSocket adapter with the router for control plane multiplexing - const wsAdapter = new WebSocketAdapter(router, logger); + const ptyManager = container.get('ptyManager'); + const wsAdapter = new WebSocketAdapter(router, ptyManager, logger); return { fetch: async ( @@ -134,6 +135,10 @@ export async function startServer(): Promise { try { const processService = app.container.get('processService'); const portService = app.container.get('portService'); + const ptyManager = app.container.get('ptyManager'); + + // Kill all PTY sessions + ptyManager.killAll(); await processService.destroy(); portService.destroy(); diff --git a/packages/sandbox-container/src/services/process-service.ts b/packages/sandbox-container/src/services/process-service.ts index 38038af1..989c81c5 100644 --- a/packages/sandbox-container/src/services/process-service.ts +++ b/packages/sandbox-container/src/services/process-service.ts @@ -54,6 +54,7 @@ export class ProcessService { try { // Always use SessionManager for execution (unified model) const sessionId = options.sessionId || 'default'; + const result = await this.sessionManager.executeInSession( sessionId, command, @@ -110,6 +111,8 @@ export class ProcessService { options: ProcessOptions = {} ): Promise> { try { + const sessionId = options.sessionId || 'default'; + // 1. Validate command (business logic via manager) const validation = this.manager.validateCommand(command); if (!validation.valid) { @@ -130,7 +133,6 @@ export class ProcessService { ); // 3. Build full process record with commandHandle instead of subprocess - const sessionId = options.sessionId || 'default'; const processRecord: ProcessRecord = { ...processRecordData, commandHandle: { diff --git a/packages/sandbox-container/src/services/session-manager.ts b/packages/sandbox-container/src/services/session-manager.ts index f2f87d20..2023a6a6 100644 --- a/packages/sandbox-container/src/services/session-manager.ts +++ b/packages/sandbox-container/src/services/session-manager.ts @@ -222,6 +222,23 @@ export class SessionManager { }; } + /** + * Get session info (cwd, env) for PTY attachment. + * Returns null if session doesn't exist. + */ + getSessionInfo( + sessionId: string + ): { cwd: string; env?: Record } | null { + const session = this.sessions.get(sessionId); + if (!session) { + return null; + } + return { + cwd: session.getInitialCwd(), + env: session.getInitialEnv() + }; + } + /** * Execute a command in a session with per-session locking. * Commands to the same session are serialized; different sessions run in parallel. diff --git a/packages/sandbox-container/src/session.ts b/packages/sandbox-container/src/session.ts index 3961892f..01938e79 100644 --- a/packages/sandbox-container/src/session.ts +++ b/packages/sandbox-container/src/session.ts @@ -203,6 +203,20 @@ export class Session { this.logger = options.logger ?? createNoOpLogger(); } + /** + * Get the initial working directory configured for this session. + */ + getInitialCwd(): string { + return this.options.cwd || CONFIG.DEFAULT_CWD; + } + + /** + * Get the initial environment variables configured for this session. + */ + getInitialEnv(): Record | undefined { + return this.options.env; + } + /** * Initialize the session by spawning a persistent bash shell */ diff --git a/packages/sandbox-container/tests/handlers/ws-adapter.test.ts b/packages/sandbox-container/tests/handlers/ws-adapter.test.ts index 93d91ab0..360dedb4 100644 --- a/packages/sandbox-container/tests/handlers/ws-adapter.test.ts +++ b/packages/sandbox-container/tests/handlers/ws-adapter.test.ts @@ -6,6 +6,7 @@ import { WebSocketAdapter, type WSData } from '../../src/handlers/ws-adapter'; +import type { PtyManager } from '../../src/managers/pty-manager'; // Mock ServerWebSocket class MockServerWebSocket { @@ -47,17 +48,29 @@ function createMockLogger(): Logger { } as unknown as Logger; } +// Mock PtyManager +function createMockPtyManager(): PtyManager { + return { + write: vi.fn(), + resize: vi.fn(), + onData: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}) + } as unknown as PtyManager; +} + describe('WebSocketAdapter', () => { let adapter: WebSocketAdapter; let mockRouter: Router; + let mockPtyManager: PtyManager; let mockLogger: Logger; let mockWs: MockServerWebSocket; beforeEach(() => { vi.clearAllMocks(); mockRouter = createMockRouter(); + mockPtyManager = createMockPtyManager(); mockLogger = createMockLogger(); - adapter = new WebSocketAdapter(mockRouter, mockLogger); + adapter = new WebSocketAdapter(mockRouter, mockPtyManager, mockLogger); mockWs = new MockServerWebSocket({ connectionId: 'test-conn-123' }); }); @@ -277,12 +290,14 @@ describe('WebSocketAdapter', () => { describe('WebSocket Integration', () => { let adapter: WebSocketAdapter; let mockRouter: Router; + let mockPtyManager: PtyManager; let mockLogger: Logger; beforeEach(() => { mockRouter = createMockRouter(); + mockPtyManager = createMockPtyManager(); mockLogger = createMockLogger(); - adapter = new WebSocketAdapter(mockRouter, mockLogger); + adapter = new WebSocketAdapter(mockRouter, mockPtyManager, mockLogger); }); it('should handle multiple concurrent requests', async () => { @@ -364,3 +379,120 @@ describe('WebSocket Integration', () => { expect(successMsg.status).toBe(200); }); }); + +describe('WebSocket PTY Listener Cleanup', () => { + let adapter: WebSocketAdapter; + let mockRouter: Router; + let mockPtyManager: PtyManager; + let mockLogger: Logger; + let childLogger: Logger; + + beforeEach(() => { + mockRouter = createMockRouter(); + mockPtyManager = createMockPtyManager(); + // Create a child logger that we can track + childLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + child: vi.fn(() => childLogger) + } as unknown as Logger; + mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + child: vi.fn(() => childLogger) + } as unknown as Logger; + adapter = new WebSocketAdapter(mockRouter, mockPtyManager, mockLogger); + }); + + it('should register PTY listener and return cleanup function', () => { + const mockWs = new MockServerWebSocket({ connectionId: 'pty-test-1' }); + + const cleanup = adapter.registerPtyListener(mockWs as any, 'pty_123'); + + expect(typeof cleanup).toBe('function'); + expect(mockPtyManager.onData).toHaveBeenCalledWith( + 'pty_123', + expect.any(Function) + ); + expect(mockPtyManager.onExit).toHaveBeenCalledWith( + 'pty_123', + expect.any(Function) + ); + }); + + it('should clean up PTY listeners when connection closes', () => { + const mockWs = new MockServerWebSocket({ + connectionId: 'pty-cleanup-test' + }); + + // Register multiple PTY listeners + adapter.registerPtyListener(mockWs as any, 'pty_1'); + adapter.registerPtyListener(mockWs as any, 'pty_2'); + + // Simulate connection close + adapter.onClose(mockWs as any, 1000, 'Normal closure'); + + // Should log cleanup (using childLogger since adapter calls logger.child()) + expect(childLogger.debug).toHaveBeenCalledWith( + 'Cleaning up PTY listeners for closed connection', + expect.objectContaining({ + connectionId: 'pty-cleanup-test', + listenerCount: 2 + }) + ); + }); + + it('should handle cleanup being called multiple times safely', () => { + const mockWs = new MockServerWebSocket({ + connectionId: 'double-cleanup-test' + }); + + const cleanup = adapter.registerPtyListener(mockWs as any, 'pty_123'); + + // Call cleanup multiple times - should not throw + cleanup(); + cleanup(); + cleanup(); + }); + + it('should not log cleanup when no listeners registered', () => { + const mockWs = new MockServerWebSocket({ + connectionId: 'no-listeners-test' + }); + + // Close without registering any listeners + adapter.onClose(mockWs as any, 1000, 'Normal closure'); + + // Should not log cleanup message (only connection closed message) + expect(childLogger.debug).not.toHaveBeenCalledWith( + 'Cleaning up PTY listeners for closed connection', + expect.anything() + ); + }); + + it('should remove cleanup from tracking after manual cleanup', () => { + const mockWs = new MockServerWebSocket({ + connectionId: 'manual-cleanup-test' + }); + + // Register and immediately cleanup + const cleanup = adapter.registerPtyListener(mockWs as any, 'pty_123'); + cleanup(); + + // Reset the mock to clear any previous calls + (childLogger.debug as ReturnType).mockClear(); + + // Now close - should not have any listeners to clean + adapter.onClose(mockWs as any, 1000, 'Normal closure'); + + // Should not log cleanup message since we already cleaned up + expect(childLogger.debug).not.toHaveBeenCalledWith( + 'Cleaning up PTY listeners for closed connection', + expect.anything() + ); + }); +}); diff --git a/packages/sandbox-container/tests/managers/pty-manager.test.ts b/packages/sandbox-container/tests/managers/pty-manager.test.ts new file mode 100644 index 00000000..75856a8e --- /dev/null +++ b/packages/sandbox-container/tests/managers/pty-manager.test.ts @@ -0,0 +1,466 @@ +import type { Logger } from '@repo/shared'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { PtyManager } from '../../src/managers/pty-manager'; + +/** + * PtyManager Unit Tests + * + * Tests dimension validation, exited PTY handling, and error handling. + * + * Note: Tests that require actual PTY creation only run when the environment + * supports it (Bun.Terminal available AND /dev/pts accessible). This is typically + * only true inside the Docker container. Full PTY lifecycle testing is covered + * by E2E tests which run in the actual container environment. + */ + +function createMockLogger(): Logger { + const logger: Logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + child: vi.fn(() => logger) + }; + return logger; +} + +/** + * Check if the environment can actually create PTYs. + * Bun.Terminal may exist but fail if /dev/pts is not mounted. + */ +function canCreatePty(): boolean { + if (typeof Bun === 'undefined') return false; + const BunTerminal = (Bun as { Terminal?: unknown }).Terminal; + if (!BunTerminal) return false; + + // Try to actually create a PTY to verify the environment supports it + try { + const testLogger = createMockLogger(); + const testManager = new PtyManager(testLogger); + const session = testManager.create({ + cols: 80, + rows: 24, + command: ['/bin/true'] + }); + testManager.kill(session.id); + return true; + } catch { + // PTY creation failed - likely missing /dev/pts or permissions + return false; + } +} + +// Cache the result since it won't change during test run +const ptySupported = canCreatePty(); + +describe('PtyManager', () => { + let manager: PtyManager; + let mockLogger: Logger; + + beforeEach(() => { + vi.clearAllMocks(); + mockLogger = createMockLogger(); + manager = new PtyManager(mockLogger); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('write - unknown and exited PTY handling', () => { + it('should return error when writing to unknown PTY', () => { + const result = manager.write('pty_nonexistent_12345', 'hello'); + expect(result.success).toBe(false); + expect(result.error).toBe('PTY not found'); + }); + + it('should log warning when writing to unknown PTY', () => { + manager.write('pty_nonexistent_12345', 'hello'); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Write to unknown PTY', + expect.objectContaining({ ptyId: 'pty_nonexistent_12345' }) + ); + }); + }); + + describe('resize - unknown PTY handling', () => { + it('should return error when resizing unknown PTY', () => { + const result = manager.resize('pty_nonexistent_12345', 100, 50); + expect(result.success).toBe(false); + expect(result.error).toBe('PTY not found'); + }); + + it('should log warning when resizing unknown PTY', () => { + manager.resize('pty_nonexistent_12345', 100, 50); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Resize unknown PTY', + expect.objectContaining({ ptyId: 'pty_nonexistent_12345' }) + ); + }); + }); + + describe('listener registration for unknown PTY', () => { + it('should return no-op unsubscribe for unknown PTY onData', () => { + const callback = vi.fn(); + const unsubscribe = manager.onData('pty_nonexistent', callback); + + // Should return a function that does nothing + expect(typeof unsubscribe).toBe('function'); + unsubscribe(); // Should not throw + }); + + it('should warn when registering onData for unknown PTY', () => { + const callback = vi.fn(); + manager.onData('pty_nonexistent', callback); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Registering onData listener for unknown PTY - callback will never fire', + expect.objectContaining({ ptyId: 'pty_nonexistent' }) + ); + }); + + it('should return no-op unsubscribe for unknown PTY onExit', () => { + const callback = vi.fn(); + const unsubscribe = manager.onExit('pty_nonexistent', callback); + + // Should return a function that does nothing + expect(typeof unsubscribe).toBe('function'); + unsubscribe(); // Should not throw + }); + + it('should warn when registering onExit for unknown PTY', () => { + const callback = vi.fn(); + manager.onExit('pty_nonexistent', callback); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Registering onExit listener for unknown PTY - callback will never fire', + expect.objectContaining({ ptyId: 'pty_nonexistent' }) + ); + }); + }); + + describe('get and list operations', () => { + it('should return null for unknown PTY', () => { + const result = manager.get('pty_nonexistent_12345'); + expect(result).toBeNull(); + }); + + it('should return empty list when no PTYs exist', () => { + const result = manager.list(); + expect(result).toEqual([]); + }); + }); + + describe('kill and cleanup operations', () => { + it('should return error when killing unknown PTY', () => { + const result = manager.kill('pty_nonexistent_12345'); + expect(result.success).toBe(false); + expect(result.error).toBe('PTY not found'); + }); + + it('should log warning when killing unknown PTY', () => { + manager.kill('pty_nonexistent_12345'); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Kill unknown PTY', + expect.objectContaining({ ptyId: 'pty_nonexistent_12345' }) + ); + }); + + it('should handle cleanup of unknown PTY gracefully', () => { + // Should not throw + manager.cleanup('pty_nonexistent_12345'); + }); + + it('should handle killAll with no PTYs gracefully', () => { + // Should not throw + manager.killAll(); + }); + }); + + describe('concurrent listener registration', () => { + it('should handle multiple onData registrations for same unknown PTY', () => { + const callback1 = vi.fn(); + const callback2 = vi.fn(); + const callback3 = vi.fn(); + + const unsub1 = manager.onData('pty_nonexistent', callback1); + const unsub2 = manager.onData('pty_nonexistent', callback2); + const unsub3 = manager.onData('pty_nonexistent', callback3); + + // All should return no-op functions + expect(typeof unsub1).toBe('function'); + expect(typeof unsub2).toBe('function'); + expect(typeof unsub3).toBe('function'); + + // All unsubscribes should be safe to call + unsub1(); + unsub2(); + unsub3(); + + // Should have warned for each registration + expect(mockLogger.warn).toHaveBeenCalledTimes(3); + }); + + it('should handle multiple onExit registrations for same unknown PTY', () => { + const callback1 = vi.fn(); + const callback2 = vi.fn(); + + const unsub1 = manager.onExit('pty_nonexistent', callback1); + const unsub2 = manager.onExit('pty_nonexistent', callback2); + + unsub1(); + unsub2(); + + expect(mockLogger.warn).toHaveBeenCalledTimes(2); + }); + }); + + describe('callback error handling', () => { + it('should log error when onExit immediate callback throws', () => { + // This test verifies the error is logged, but we can't easily test + // with a real PTY. The behavior is tested in E2E tests. + // Here we just verify the manager handles unknown PTY gracefully. + const throwingCallback = () => { + throw new Error('Callback error'); + }; + + // Registration on unknown PTY returns no-op, callback never called + const unsub = manager.onExit('pty_nonexistent', throwingCallback); + unsub(); + + // Should warn about unknown PTY, not error from callback + expect(mockLogger.warn).toHaveBeenCalled(); + expect(mockLogger.error).not.toHaveBeenCalled(); + }); + }); +}); + +/** + * Dimension Validation Tests + * + * These tests verify the dimension validation logic in PtyManager. + * They require actual PTY creation, so they only run in environments + * with full PTY support (typically the Docker container). + * + * Skipped in CI/local environments without /dev/pts access. + * Full coverage is provided by E2E tests. + */ +describe.skipIf(!ptySupported)('PtyManager - Dimension Validation', () => { + let manager: PtyManager; + let mockLogger: Logger; + + beforeEach(() => { + vi.clearAllMocks(); + mockLogger = createMockLogger(); + manager = new PtyManager(mockLogger); + }); + + afterEach(() => { + vi.restoreAllMocks(); + // Clean up any created PTYs + manager.killAll(); + }); + + describe('create - dimension validation', () => { + it('should reject cols below minimum (0)', () => { + expect(() => manager.create({ cols: 0, rows: 24 })).toThrow( + /Invalid cols: 0.*Must be between 1 and 1000/ + ); + }); + + it('should reject cols above maximum (1001)', () => { + expect(() => manager.create({ cols: 1001, rows: 24 })).toThrow( + /Invalid cols: 1001.*Must be between 1 and 1000/ + ); + }); + + it('should reject rows below minimum (0)', () => { + expect(() => manager.create({ cols: 80, rows: 0 })).toThrow( + /Invalid rows: 0.*Must be between 1 and 1000/ + ); + }); + + it('should reject rows above maximum (1001)', () => { + expect(() => manager.create({ cols: 80, rows: 1001 })).toThrow( + /Invalid rows: 1001.*Must be between 1 and 1000/ + ); + }); + + it('should accept minimum valid dimensions (1x1)', () => { + const session = manager.create({ + cols: 1, + rows: 1, + command: ['/bin/true'] + }); + expect(session.cols).toBe(1); + expect(session.rows).toBe(1); + }); + + it('should accept maximum valid dimensions (1000x1000)', () => { + const session = manager.create({ + cols: 1000, + rows: 1000, + command: ['/bin/true'] + }); + expect(session.cols).toBe(1000); + expect(session.rows).toBe(1000); + }); + + it('should accept typical terminal dimensions (80x24)', () => { + const session = manager.create({ + cols: 80, + rows: 24, + command: ['/bin/true'] + }); + expect(session.cols).toBe(80); + expect(session.rows).toBe(24); + }); + }); + + describe('resize - dimension validation with running PTY', () => { + it('should reject resize with cols below minimum (0)', async () => { + const session = manager.create({ + cols: 80, + rows: 24, + command: ['/bin/sleep', '10'] + }); + const result = manager.resize(session.id, 0, 24); + expect(result.success).toBe(false); + expect(result.error).toMatch( + /Invalid dimensions.*Must be between 1 and 1000/ + ); + manager.kill(session.id); + }); + + it('should reject resize with cols above maximum (1001)', async () => { + const session = manager.create({ + cols: 80, + rows: 24, + command: ['/bin/sleep', '10'] + }); + const result = manager.resize(session.id, 1001, 24); + expect(result.success).toBe(false); + expect(result.error).toMatch( + /Invalid dimensions.*Must be between 1 and 1000/ + ); + manager.kill(session.id); + }); + + it('should reject resize with rows below minimum (0)', async () => { + const session = manager.create({ + cols: 80, + rows: 24, + command: ['/bin/sleep', '10'] + }); + const result = manager.resize(session.id, 80, 0); + expect(result.success).toBe(false); + expect(result.error).toMatch( + /Invalid dimensions.*Must be between 1 and 1000/ + ); + manager.kill(session.id); + }); + + it('should reject resize with rows above maximum (1001)', async () => { + const session = manager.create({ + cols: 80, + rows: 24, + command: ['/bin/sleep', '10'] + }); + const result = manager.resize(session.id, 80, 1001); + expect(result.success).toBe(false); + expect(result.error).toMatch( + /Invalid dimensions.*Must be between 1 and 1000/ + ); + manager.kill(session.id); + }); + + it('should accept resize with valid dimensions', async () => { + const session = manager.create({ + cols: 80, + rows: 24, + command: ['/bin/sleep', '10'] + }); + const result = manager.resize(session.id, 100, 50); + expect(result.success).toBe(true); + manager.kill(session.id); + }); + + it('should log warning for invalid dimensions', async () => { + const session = manager.create({ + cols: 80, + rows: 24, + command: ['/bin/sleep', '10'] + }); + manager.resize(session.id, 0, 24); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Invalid resize dimensions', + expect.objectContaining({ ptyId: session.id, cols: 0, rows: 24 }) + ); + manager.kill(session.id); + }); + }); + + describe('write and resize on exited PTY', () => { + it('should return error when writing to exited PTY', async () => { + // Create a PTY that exits immediately + const session = manager.create({ + cols: 80, + rows: 24, + command: ['/bin/true'] + }); + + // Wait for the process to exit + await session.process.exited; + + // Try to write to the exited PTY + const result = manager.write(session.id, 'hello'); + expect(result.success).toBe(false); + expect(result.error).toBe('PTY has exited'); + }); + + it('should return error when resizing exited PTY', async () => { + // Create a PTY that exits immediately + const session = manager.create({ + cols: 80, + rows: 24, + command: ['/bin/true'] + }); + + // Wait for the process to exit + await session.process.exited; + + // Try to resize the exited PTY + const result = manager.resize(session.id, 100, 50); + expect(result.success).toBe(false); + expect(result.error).toBe('PTY has exited'); + }); + + it('should log warning when writing to exited PTY', async () => { + const session = manager.create({ + cols: 80, + rows: 24, + command: ['/bin/true'] + }); + await session.process.exited; + + manager.write(session.id, 'hello'); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Write to exited PTY', + expect.objectContaining({ ptyId: session.id }) + ); + }); + + it('should log warning when resizing exited PTY', async () => { + const session = manager.create({ + cols: 80, + rows: 24, + command: ['/bin/true'] + }); + await session.process.exited; + + manager.resize(session.id, 100, 50); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Resize exited PTY', + expect.objectContaining({ ptyId: session.id }) + ); + }); + }); +}); diff --git a/packages/sandbox/src/clients/index.ts b/packages/sandbox/src/clients/index.ts index 83b636f4..126e7516 100644 --- a/packages/sandbox/src/clients/index.ts +++ b/packages/sandbox/src/clients/index.ts @@ -15,6 +15,7 @@ export { GitClient } from './git-client'; export { InterpreterClient } from './interpreter-client'; export { PortClient } from './port-client'; export { ProcessClient } from './process-client'; +export { PtyClient } from './pty-client'; export { UtilityClient } from './utility-client'; // ============================================================================= @@ -38,6 +39,7 @@ export { // Client types and interfaces // ============================================================================= +export type { PtyInfo } from '@repo/shared'; // Command client types export type { ExecuteRequest, ExecuteResponse } from './command-client'; // File client types @@ -69,6 +71,8 @@ export type { ProcessStartResult, StartProcessRequest } from './process-client'; +// PTY client types +export type { Pty } from './pty-client'; // Core types export type { BaseApiResponse, diff --git a/packages/sandbox/src/clients/pty-client.ts b/packages/sandbox/src/clients/pty-client.ts new file mode 100644 index 00000000..c56a6404 --- /dev/null +++ b/packages/sandbox/src/clients/pty-client.ts @@ -0,0 +1,411 @@ +import type { + CreatePtyOptions, + Logger, + PtyCreateResult, + PtyGetResult, + PtyInfo, + PtyListResult +} from '@repo/shared'; +import { createNoOpLogger } from '@repo/shared'; +import { BaseHttpClient } from './base-client'; +import type { ITransport } from './transport/types'; +import { WebSocketTransport } from './transport/ws-transport'; + +/** + * PTY handle returned by create/get + * + * Provides methods for interacting with a PTY session: + * - write: Send input to the terminal (returns Promise for error handling) + * - resize: Change terminal dimensions (returns Promise for error handling) + * - kill: Terminate the PTY process + * - onData: Listen for output data + * - onExit: Listen for process exit + * - close: Detach from PTY (PTY continues running) + */ +export interface Pty extends AsyncIterable { + /** Unique PTY identifier */ + readonly id: string; + /** Promise that resolves when PTY exits */ + readonly exited: Promise<{ exitCode: number }>; + + /** + * Send input to PTY + * + * Returns a Promise that resolves on success or rejects on failure. + * For interactive typing, you can ignore the promise (fire-and-forget). + * For programmatic commands, await to catch errors. + */ + write(data: string): Promise; + + /** + * Resize terminal + * + * Returns a Promise that resolves on success or rejects on failure. + */ + resize(cols: number, rows: number): Promise; + + /** Kill the PTY process */ + kill(signal?: string): Promise; + + /** Register data listener */ + onData(callback: (data: string) => void): () => void; + + /** Register exit listener */ + onExit(callback: (exitCode: number) => void): () => void; + + /** Detach from PTY (PTY keeps running) */ + close(): void; +} + +/** + * Internal PTY handle implementation + * + * Uses WebSocket transport for real-time PTY I/O via generic sendMessage() + * and onStreamEvent() methods. PTY requires WebSocket for bidirectional + * real-time communication. + */ +class PtyHandle implements Pty { + readonly exited: Promise<{ exitCode: number }>; + private closed = false; + private dataListeners: Array<() => void> = []; + private exitListeners: Array<() => void> = []; + + constructor( + readonly id: string, + private transport: ITransport, + private logger: Logger + ) { + // Setup exit promise using generic stream event listener + this.exited = new Promise((resolve) => { + const unsub = this.transport.onStreamEvent( + this.id, + 'pty_exit', + (data: string) => { + unsub(); + try { + const { exitCode } = JSON.parse(data); + resolve({ exitCode }); + } catch { + // If parse fails, resolve with default exit code + resolve({ exitCode: 1 }); + } + } + ); + this.exitListeners.push(unsub); + }); + } + + async write(data: string): Promise { + if (this.closed) { + throw new Error('PTY is closed'); + } + + try { + // Use generic sendMessage with PTY input payload + this.transport.sendMessage({ type: 'pty_input', ptyId: this.id, data }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.error( + 'PTY write failed', + error instanceof Error ? error : undefined, + { ptyId: this.id } + ); + throw new Error(`PTY write failed: ${message}`); + } + } + + async resize(cols: number, rows: number): Promise { + if (this.closed) { + throw new Error('PTY is closed'); + } + + try { + // Use generic sendMessage with PTY resize payload + this.transport.sendMessage({ + type: 'pty_resize', + ptyId: this.id, + cols, + rows + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.error( + 'PTY resize failed', + error instanceof Error ? error : undefined, + { ptyId: this.id, cols, rows } + ); + throw new Error(`PTY resize failed: ${message}`); + } + } + + async kill(signal?: string): Promise { + const body = signal ? JSON.stringify({ signal }) : undefined; + const response = await this.transport.fetch(`/api/pty/${this.id}`, { + method: 'DELETE', + headers: body ? { 'Content-Type': 'application/json' } : undefined, + body + }); + + if (!response.ok) { + const text = await response.text().catch(() => 'Unknown error'); + this.logger.error('PTY kill failed', undefined, { + ptyId: this.id, + signal, + status: response.status, + error: text + }); + throw new Error(`PTY kill failed: HTTP ${response.status}: ${text}`); + } + } + + onData(callback: (data: string) => void): () => void { + if (this.closed) { + this.logger.warn( + 'Registering onData listener on closed PTY handle - callback will never fire', + { ptyId: this.id } + ); + return () => {}; + } + + // Use generic stream event listener + const unsub = this.transport.onStreamEvent(this.id, 'pty_data', callback); + this.dataListeners.push(unsub); + return unsub; + } + + onExit(callback: (exitCode: number) => void): () => void { + if (this.closed) { + this.logger.warn( + 'Registering onExit listener on closed PTY handle - callback will never fire', + { ptyId: this.id } + ); + return () => {}; + } + + // Use generic stream event listener, parse exitCode from JSON data + const unsub = this.transport.onStreamEvent( + this.id, + 'pty_exit', + (data: string) => { + try { + const { exitCode } = JSON.parse(data); + callback(exitCode); + } catch { + callback(1); // Default exit code on parse failure + } + } + ); + this.exitListeners.push(unsub); + return unsub; + } + + close(): void { + if (this.closed) return; + this.closed = true; + + // Unsubscribe all listeners + for (const unsub of this.dataListeners) { + unsub(); + } + for (const unsub of this.exitListeners) { + unsub(); + } + this.dataListeners = []; + this.exitListeners = []; + } + + async *[Symbol.asyncIterator](): AsyncIterator { + const queue: string[] = []; + let resolve: (() => void) | null = null; + let done = false; + + const unsubData = this.onData((data) => { + queue.push(data); + resolve?.(); + }); + + const unsubExit = this.onExit(() => { + done = true; + resolve?.(); + }); + + try { + while (!done || queue.length > 0) { + if (queue.length > 0) { + yield queue.shift()!; + } else if (!done) { + await new Promise((r) => { + resolve = r; + }); + resolve = null; + } + } + } finally { + unsubData(); + unsubExit(); + } + } +} + +/** + * Client for PTY operations + * + * Provides methods to create and manage pseudo-terminal sessions in the sandbox. + * PTY operations require WebSocket transport for real-time bidirectional communication. + * The client automatically creates and manages a dedicated WebSocket connection. + */ +export class PtyClient extends BaseHttpClient { + /** Dedicated WebSocket transport for PTY real-time communication */ + private ptyTransport: WebSocketTransport | null = null; + + /** + * Get or create the dedicated WebSocket transport for PTY operations + * + * PTY requires WebSocket for continuous bidirectional communication. + * This method lazily creates a WebSocket connection on first use. + */ + private async getPtyTransport(): Promise { + if (this.ptyTransport?.isConnected()) { + return this.ptyTransport; + } + + // Build WebSocket URL from HTTP client options + const wsUrl = this.options.wsUrl ?? this.buildWsUrl(); + + this.ptyTransport = new WebSocketTransport({ + wsUrl, + baseUrl: this.options.baseUrl, + logger: this.options.logger ?? createNoOpLogger(), + stub: this.options.stub, + port: this.options.port + }); + + await this.ptyTransport.connect(); + this.logger.debug('PTY WebSocket transport connected', { wsUrl }); + + return this.ptyTransport; + } + + /** + * Build WebSocket URL from HTTP base URL + */ + private buildWsUrl(): string { + const baseUrl = this.options.baseUrl ?? 'http://localhost:3000'; + // Convert http(s) to ws(s) + const wsUrl = baseUrl.replace(/^http/, 'ws'); + return `${wsUrl}/ws`; + } + + /** + * Disconnect the PTY WebSocket transport + * Called when the sandbox is destroyed or PTY operations are no longer needed. + */ + disconnectPtyTransport(): void { + if (this.ptyTransport) { + this.ptyTransport.disconnect(); + this.ptyTransport = null; + } + } + + /** + * Create a new PTY session + * + * @param options - PTY creation options (terminal size, command, cwd, etc.) + * @returns PTY handle for interacting with the terminal + * + * @example + * const pty = await client.create({ cols: 80, rows: 24 }); + * pty.onData((data) => console.log(data)); + * pty.write('ls -la\n'); + */ + async create(options?: CreatePtyOptions): Promise { + // Ensure WebSocket transport is connected for real-time PTY I/O + const ptyTransport = await this.getPtyTransport(); + + const response = await this.post( + '/api/pty', + options ?? {} + ); + + if (!response.success) { + throw new Error('Failed to create PTY'); + } + + this.logSuccess('PTY created', response.pty.id); + + // Pass the dedicated WebSocket transport to the PTY handle + return new PtyHandle(response.pty.id, ptyTransport, this.logger); + } + + /** + * Get an existing PTY by ID + * + * @param id - PTY ID + * @returns PTY handle + * + * @example + * const pty = await client.getById('pty_123'); + */ + async getById(id: string): Promise { + // Ensure WebSocket transport is connected for real-time PTY I/O + const ptyTransport = await this.getPtyTransport(); + + const response = await this.doFetch(`/api/pty/${id}`, { + method: 'GET' + }); + + // Use handleResponse to properly parse ErrorResponse on failure + const result = await this.handleResponse(response); + + this.logSuccess('PTY retrieved', id); + + return new PtyHandle(result.pty.id, ptyTransport, this.logger); + } + + /** + * List all active PTY sessions + * + * @returns Array of PTY info objects + * + * @example + * const ptys = await client.list(); + * console.log(`Found ${ptys.length} PTY sessions`); + */ + async list(): Promise { + const response = await this.doFetch('/api/pty', { + method: 'GET' + }); + + // Use handleResponse to properly parse ErrorResponse on failure + const result = await this.handleResponse(response); + + this.logSuccess('PTYs listed', `${result.ptys.length} found`); + + return result.ptys; + } + + /** + * Get PTY information by ID (without creating a handle) + * + * Use this when you need raw PTY info for serialization or inspection. + * For interactive PTY usage, prefer getById() which returns a handle. + * + * @param id - PTY ID + * @returns PTY info object + */ + async getInfo(id: string): Promise { + const response = await this.doFetch(`/api/pty/${id}`, { + method: 'GET' + }); + + const result: PtyGetResult = await response.json(); + + if (!result.success) { + throw new Error('PTY not found'); + } + + this.logSuccess('PTY info retrieved', id); + + return result.pty; + } +} diff --git a/packages/sandbox/src/clients/sandbox-client.ts b/packages/sandbox/src/clients/sandbox-client.ts index 67aca6f1..a0fd9b6a 100644 --- a/packages/sandbox/src/clients/sandbox-client.ts +++ b/packages/sandbox/src/clients/sandbox-client.ts @@ -4,6 +4,7 @@ import { GitClient } from './git-client'; import { InterpreterClient } from './interpreter-client'; import { PortClient } from './port-client'; import { ProcessClient } from './process-client'; +import { PtyClient } from './pty-client'; import { createTransport, type ITransport, @@ -30,6 +31,7 @@ export class SandboxClient { public readonly git: GitClient; public readonly interpreter: InterpreterClient; public readonly utils: UtilityClient; + public readonly pty: PtyClient; private transport: ITransport | null = null; @@ -62,6 +64,7 @@ export class SandboxClient { this.git = new GitClient(clientOptions); this.interpreter = new InterpreterClient(clientOptions); this.utils = new UtilityClient(clientOptions); + this.pty = new PtyClient(clientOptions); } /** diff --git a/packages/sandbox/src/clients/transport/base-transport.ts b/packages/sandbox/src/clients/transport/base-transport.ts index 68cde407..da147fb8 100644 --- a/packages/sandbox/src/clients/transport/base-transport.ts +++ b/packages/sandbox/src/clients/transport/base-transport.ts @@ -13,6 +13,9 @@ const MIN_TIME_FOR_RETRY_MS = 15_000; // Need at least 15s remaining to retry * * Handles 503 retry for container startup - shared by all transports. * Subclasses implement the transport-specific fetch and stream logic. + * + * For real-time messaging (sendMessage, onStreamEvent), WebSocket implements + * these methods while HTTP throws clear errors explaining WebSocket is required. */ export abstract class BaseTransport implements ITransport { protected config: TransportConfig; @@ -27,6 +30,12 @@ export abstract class BaseTransport implements ITransport { abstract connect(): Promise; abstract disconnect(): void; abstract isConnected(): boolean; + abstract sendMessage(message: object): void; + abstract onStreamEvent( + streamId: string, + event: string, + callback: (data: string) => void + ): () => void; /** * Fetch with automatic retry for 503 (container starting) diff --git a/packages/sandbox/src/clients/transport/http-transport.ts b/packages/sandbox/src/clients/transport/http-transport.ts index 6bcdf1c1..0fc5e2c6 100644 --- a/packages/sandbox/src/clients/transport/http-transport.ts +++ b/packages/sandbox/src/clients/transport/http-transport.ts @@ -6,6 +6,10 @@ import type { TransportConfig, TransportMode } from './types'; * * Uses standard fetch API for communication with the container. * HTTP is stateless, so connect/disconnect are no-ops. + * + * Real-time messaging (sendMessage, onStreamEvent) is NOT supported. + * Features requiring real-time bidirectional communication (like PTY) + * must use WebSocket transport. */ export class HttpTransport extends BaseTransport { private baseUrl: string; @@ -31,6 +35,36 @@ export class HttpTransport extends BaseTransport { return true; // HTTP is always "connected" } + /** + * HTTP does not support real-time messaging. + * @throws Error explaining WebSocket is required + */ + sendMessage(_message: object): void { + throw new Error( + 'Real-time messaging requires WebSocket transport. ' + + 'PTY operations need continuous bidirectional communication. ' + + 'Use useWebSocket: true in sandbox options, or call sandbox.pty.create() ' + + 'which automatically uses WebSocket.' + ); + } + + /** + * HTTP does not support real-time event streaming. + * @throws Error explaining WebSocket is required + */ + onStreamEvent( + _streamId: string, + _event: string, + _callback: (data: string) => void + ): () => void { + throw new Error( + 'Real-time event streaming requires WebSocket transport. ' + + 'PTY data/exit events need continuous bidirectional communication. ' + + 'Use useWebSocket: true in sandbox options, or call sandbox.pty.create() ' + + 'which automatically uses WebSocket.' + ); + } + protected async doFetch( path: string, options?: RequestInit diff --git a/packages/sandbox/src/clients/transport/types.ts b/packages/sandbox/src/clients/transport/types.ts index 7eb57eb7..7b06beb3 100644 --- a/packages/sandbox/src/clients/transport/types.ts +++ b/packages/sandbox/src/clients/transport/types.ts @@ -33,10 +33,14 @@ export interface TransportConfig { } /** - * Transport interface - all transports must implement this + * Core transport interface - all transports must implement this * * Provides a unified abstraction over HTTP and WebSocket communication. * Both transports support fetch-compatible requests and streaming. + * + * For real-time bidirectional communication (like PTY), use the generic + * sendMessage() and onStreamEvent() methods which WebSocket implements. + * HTTP transport throws clear errors for these operations. */ export interface ITransport { /** @@ -74,4 +78,33 @@ export interface ITransport { * Check if connected (always true for HTTP) */ isConnected(): boolean; + + /** + * Send a message over the transport (WebSocket only) + * + * Used for real-time bidirectional communication like PTY input/resize. + * HTTP transport throws an error - use fetch() for HTTP operations. + * + * @param message - Message object to send (will be JSON serialized) + * @throws Error if transport doesn't support real-time messaging + */ + sendMessage(message: object): void; + + /** + * Register a listener for stream events (WebSocket only) + * + * Used for real-time bidirectional communication like PTY data/exit events. + * HTTP transport throws an error - use fetchStream() for SSE streams. + * + * @param streamId - Stream identifier (e.g., PTY ID) + * @param event - Event type to listen for (e.g., 'pty_data', 'pty_exit') + * @param callback - Callback function to invoke when event is received + * @returns Unsubscribe function + * @throws Error if transport doesn't support real-time messaging + */ + onStreamEvent( + streamId: string, + event: string, + callback: (data: string) => void + ): () => void; } diff --git a/packages/sandbox/src/clients/transport/ws-transport.ts b/packages/sandbox/src/clients/transport/ws-transport.ts index ec5b0620..2b041e64 100644 --- a/packages/sandbox/src/clients/transport/ws-transport.ts +++ b/packages/sandbox/src/clients/transport/ws-transport.ts @@ -28,11 +28,19 @@ interface PendingRequest { */ type WSTransportState = 'disconnected' | 'connecting' | 'connected' | 'error'; +/** + * Stream event listener key: "streamId:event" + */ +type StreamEventKey = string; + /** * WebSocket transport implementation * * Multiplexes HTTP-like requests over a single WebSocket connection. * Useful when running inside Workers/DO where sub-request limits apply. + * + * Supports real-time bidirectional communication via sendMessage() and + * onStreamEvent() - used by PTY for input/output streaming. */ export class WebSocketTransport extends BaseTransport { private ws: WebSocket | null = null; @@ -40,6 +48,12 @@ export class WebSocketTransport extends BaseTransport { private pendingRequests: Map = new Map(); private connectPromise: Promise | null = null; + /** Generic stream event listeners keyed by "streamId:event" */ + private streamEventListeners = new Map< + StreamEventKey, + Set<(data: string) => void> + >(); + // Bound event handlers for proper add/remove private boundHandleMessage: (event: MessageEvent) => void; private boundHandleClose: (event: CloseEvent) => void; @@ -451,6 +465,18 @@ export class WebSocketTransport extends BaseTransport { } else if (isWSError(message)) { this.handleError(message); } else { + // Check for stream events (used by PTY and other real-time features) + const msg = message as { + type?: string; + id?: string; + event?: string; + data?: string; + }; + if (msg.type === 'stream' && msg.event && msg.id) { + this.dispatchStreamEvent(msg.id, msg.event, msg.data || ''); + return; + } + this.logger.warn('Unknown WebSocket message type', { message }); } } catch (error) { @@ -461,6 +487,38 @@ export class WebSocketTransport extends BaseTransport { } } + /** + * Dispatch a stream event to registered listeners + */ + private dispatchStreamEvent( + streamId: string, + event: string, + data: string + ): void { + const key = `${streamId}:${event}`; + const listeners = this.streamEventListeners.get(key); + + if (!listeners || listeners.size === 0) { + this.logger.debug('No listeners for stream event', { + streamId, + event + }); + return; + } + + for (const callback of listeners) { + try { + callback(data); + } catch (error) { + this.logger.error( + `Stream event callback error - check your ${event} handler`, + error instanceof Error ? error : new Error(String(error)), + { streamId, event } + ); + } + } + } + /** * Handle a response message */ @@ -567,8 +625,14 @@ export class WebSocketTransport extends BaseTransport { if (pending.streamController) { try { pending.streamController.error(closeError); - } catch { - // Stream may already be closed/errored + } catch (error) { + // Stream may already be closed/errored - log for visibility + this.logger.debug( + 'Stream controller already closed during WebSocket disconnect', + { + error: error instanceof Error ? error.message : String(error) + } + ); } } pending.reject(closeError); @@ -595,5 +659,60 @@ export class WebSocketTransport extends BaseTransport { } } this.pendingRequests.clear(); + // Clear stream event listeners to prevent accumulation across reconnections + this.streamEventListeners.clear(); + } + + /** + * Send a message over the WebSocket connection + * + * Used for real-time bidirectional communication (e.g., PTY input/resize). + * The message is JSON-serialized before sending. + * + * @param message - Message object to send + * @throws Error if WebSocket is not connected + */ + sendMessage(message: object): void { + if (!this.ws || this.state !== 'connected') { + throw new Error( + `Cannot send message: WebSocket not connected (state: ${this.state}). ` + + 'Call connect() first or create a new connection.' + ); + } + this.ws.send(JSON.stringify(message)); + } + + /** + * Register a listener for stream events + * + * Stream events are server-pushed messages with a specific streamId and event type. + * Used for real-time features like PTY data/exit events. + * + * @param streamId - Stream identifier (e.g., PTY ID) + * @param event - Event type to listen for (e.g., 'pty_data', 'pty_exit') + * @param callback - Callback function to invoke when event is received + * @returns Unsubscribe function + */ + onStreamEvent( + streamId: string, + event: string, + callback: (data: string) => void + ): () => void { + const key = `${streamId}:${event}`; + + if (!this.streamEventListeners.has(key)) { + this.streamEventListeners.set(key, new Set()); + } + this.streamEventListeners.get(key)!.add(callback); + + return () => { + const listeners = this.streamEventListeners.get(key); + if (listeners) { + listeners.delete(callback); + if (listeners.size === 0) { + this.streamEventListeners.delete(key); + } + } + }; } } diff --git a/packages/sandbox/src/index.ts b/packages/sandbox/src/index.ts index ea964d0e..423b40e7 100644 --- a/packages/sandbox/src/index.ts +++ b/packages/sandbox/src/index.ts @@ -99,6 +99,8 @@ export type { ExecutionCallbacks, InterpreterClient } from './clients/interpreter-client.js'; +// Export PTY types +export type { Pty } from './clients/pty-client.js'; // Export process readiness errors export { ProcessExitedBeforeReadyError, diff --git a/packages/sandbox/src/opencode/opencode.ts b/packages/sandbox/src/opencode/opencode.ts index f7626428..446d0ba6 100644 --- a/packages/sandbox/src/opencode/opencode.ts +++ b/packages/sandbox/src/opencode/opencode.ts @@ -35,7 +35,7 @@ async function ensureSdkLoaded(): Promise { } catch { throw new Error( '@opencode-ai/sdk is required for OpenCode integration. ' + - 'Install it with: npm install @opencode-ai/sdk' + 'Install it with: npm install @opencode-ai/sdk' ); } } diff --git a/packages/sandbox/src/sandbox.ts b/packages/sandbox/src/sandbox.ts index 8ca82350..6841934b 100644 --- a/packages/sandbox/src/sandbox.ts +++ b/packages/sandbox/src/sandbox.ts @@ -34,7 +34,7 @@ import { shellEscape, TraceContext } from '@repo/shared'; -import { type ExecuteResponse, SandboxClient } from './clients'; +import { type ExecuteResponse, type PtyClient, SandboxClient } from './clients'; import type { ErrorResponse } from './errors'; import { CustomDomainRequiredError, @@ -127,6 +127,24 @@ export class Sandbox extends Container implements ISandbox { client: SandboxClient; private codeInterpreter: CodeInterpreter; + + /** + * PTY (pseudo-terminal) client for interactive terminal sessions + * + * Provides methods to create and manage interactive terminal sessions: + * - create() - Create a new PTY session + * - attach(sessionId) - Attach PTY to existing session + * - getById(id) - Get existing PTY by ID + * - list() - List all active PTY sessions + * + * @example + * const pty = await sandbox.pty.create({ cols: 80, rows: 24 }); + * pty.onData((data) => terminal.write(data)); + * pty.write('ls -la\n'); + */ + get pty(): PtyClient { + return this.client.pty; + } private sandboxName: string | null = null; private normalizeId: boolean = false; private baseUrl: string | null = null; diff --git a/packages/sandbox/tests/pty-client.test.ts b/packages/sandbox/tests/pty-client.test.ts new file mode 100644 index 00000000..692798df --- /dev/null +++ b/packages/sandbox/tests/pty-client.test.ts @@ -0,0 +1,504 @@ +import type { + PtyCreateResult, + PtyGetResult, + PtyListResult +} from '@repo/shared'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { PtyClient } from '../src/clients/pty-client'; +import { WebSocketTransport } from '../src/clients/transport/ws-transport'; + +// Mock WebSocketTransport +vi.mock('../src/clients/transport/ws-transport', () => { + return { + WebSocketTransport: vi.fn().mockImplementation(() => ({ + connect: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn(), + isConnected: vi.fn().mockReturnValue(true), + getMode: vi.fn().mockReturnValue('websocket'), + fetch: vi.fn(), + fetchStream: vi.fn(), + sendMessage: vi.fn(), + onStreamEvent: vi.fn().mockReturnValue(() => {}) + })) + }; +}); + +describe('PtyClient', () => { + let client: PtyClient; + let mockFetch: ReturnType; + let mockWebSocketTransport: { + connect: ReturnType; + disconnect: ReturnType; + isConnected: ReturnType; + getMode: ReturnType; + fetch: ReturnType; + fetchStream: ReturnType; + sendMessage: ReturnType; + onStreamEvent: ReturnType; + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockFetch = vi.fn(); + global.fetch = mockFetch as unknown as typeof fetch; + + // Get reference to the mocked WebSocket transport + mockWebSocketTransport = { + connect: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn(), + isConnected: vi.fn().mockReturnValue(true), + getMode: vi.fn().mockReturnValue('websocket'), + fetch: vi.fn(), + fetchStream: vi.fn(), + sendMessage: vi.fn(), + onStreamEvent: vi.fn().mockReturnValue(() => {}) + }; + + ( + WebSocketTransport as unknown as ReturnType + ).mockImplementation(() => mockWebSocketTransport); + + client = new PtyClient({ + baseUrl: 'http://test.com', + port: 3000 + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('create', () => { + it('should create a PTY with default options', async () => { + const mockResponse: PtyCreateResult = { + success: true, + pty: { + id: 'pty_123', + cols: 80, + rows: 24, + command: ['bash'], + cwd: '/home/user', + createdAt: '2023-01-01T00:00:00Z', + state: 'running' + }, + timestamp: '2023-01-01T00:00:00Z' + }; + + mockFetch.mockResolvedValue( + new Response(JSON.stringify(mockResponse), { status: 200 }) + ); + + const pty = await client.create(); + + expect(pty.id).toBe('pty_123'); + // Verify WebSocket was connected + expect(mockWebSocketTransport.connect).toHaveBeenCalled(); + // Verify HTTP POST was made to create the PTY + expect(mockFetch).toHaveBeenCalledWith( + 'http://test.com/api/pty', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }) + ); + }); + + it('should create a PTY with custom options', async () => { + const mockResponse: PtyCreateResult = { + success: true, + pty: { + id: 'pty_456', + cols: 120, + rows: 40, + command: ['zsh'], + cwd: '/workspace', + createdAt: '2023-01-01T00:00:00Z', + state: 'running' + }, + timestamp: '2023-01-01T00:00:00Z' + }; + + mockFetch.mockResolvedValue( + new Response(JSON.stringify(mockResponse), { status: 200 }) + ); + + const pty = await client.create({ + cols: 120, + rows: 40, + command: ['zsh'], + cwd: '/workspace' + }); + + expect(pty.id).toBe('pty_456'); + const callBody = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(callBody.cols).toBe(120); + expect(callBody.rows).toBe(40); + expect(callBody.command).toEqual(['zsh']); + expect(callBody.cwd).toBe('/workspace'); + }); + + it('should handle creation errors', async () => { + const errorResponse = { + code: 'PTY_CREATE_ERROR', + message: 'Failed to create PTY', + context: {}, + httpStatus: 500, + timestamp: new Date().toISOString() + }; + + mockFetch.mockResolvedValue( + new Response(JSON.stringify(errorResponse), { status: 500 }) + ); + + await expect(client.create()).rejects.toThrow(); + }); + }); + + describe('getById', () => { + it('should get PTY by ID', async () => { + const mockResponse: PtyGetResult = { + success: true, + pty: { + id: 'pty_123', + cols: 80, + rows: 24, + command: ['bash'], + cwd: '/home/user', + createdAt: '2023-01-01T00:00:00Z', + state: 'running' + }, + timestamp: '2023-01-01T00:00:00Z' + }; + + mockFetch.mockResolvedValue( + new Response(JSON.stringify(mockResponse), { status: 200 }) + ); + + const pty = await client.getById('pty_123'); + + expect(pty.id).toBe('pty_123'); + // Verify WebSocket was connected + expect(mockWebSocketTransport.connect).toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalledWith( + 'http://test.com/api/pty/pty_123', + expect.objectContaining({ + method: 'GET' + }) + ); + }); + + it('should handle not found errors', async () => { + const errorResponse = { + code: 'PTY_NOT_FOUND', + message: 'PTY not found', + context: {}, + httpStatus: 404, + timestamp: new Date().toISOString() + }; + + mockFetch.mockResolvedValue( + new Response(JSON.stringify(errorResponse), { status: 404 }) + ); + + await expect(client.getById('nonexistent')).rejects.toThrow(); + }); + }); + + describe('list', () => { + it('should list all PTYs', async () => { + const mockResponse: PtyListResult = { + success: true, + ptys: [ + { + id: 'pty_1', + cols: 80, + rows: 24, + command: ['bash'], + cwd: '/home/user', + createdAt: '2023-01-01T00:00:00Z', + state: 'running' + }, + { + id: 'pty_2', + cols: 120, + rows: 40, + command: ['zsh'], + cwd: '/workspace', + createdAt: '2023-01-01T00:00:01Z', + state: 'exited', + exitCode: 0 + } + ], + timestamp: '2023-01-01T00:00:00Z' + }; + + mockFetch.mockResolvedValue( + new Response(JSON.stringify(mockResponse), { status: 200 }) + ); + + const ptys = await client.list(); + + expect(ptys).toHaveLength(2); + expect(ptys[0].id).toBe('pty_1'); + expect(ptys[1].id).toBe('pty_2'); + expect(ptys[1].exitCode).toBe(0); + }); + + it('should handle empty list', async () => { + const mockResponse: PtyListResult = { + success: true, + ptys: [], + timestamp: '2023-01-01T00:00:00Z' + }; + + mockFetch.mockResolvedValue( + new Response(JSON.stringify(mockResponse), { status: 200 }) + ); + + const ptys = await client.list(); + + expect(ptys).toHaveLength(0); + }); + }); + + describe('Pty handle operations', () => { + const mockCreateResponse: PtyCreateResult = { + success: true, + pty: { + id: 'pty_test', + cols: 80, + rows: 24, + command: ['bash'], + cwd: '/home/user', + createdAt: '2023-01-01T00:00:00Z', + state: 'running' + }, + timestamp: '2023-01-01T00:00:00Z' + }; + + beforeEach(() => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify(mockCreateResponse), { status: 200 }) + ); + }); + + describe('write', () => { + it('should send input via WebSocket sendMessage', async () => { + const pty = await client.create(); + + await pty.write('ls -la\n'); + + expect(mockWebSocketTransport.sendMessage).toHaveBeenCalledWith({ + type: 'pty_input', + ptyId: 'pty_test', + data: 'ls -la\n' + }); + }); + + it('should throw when PTY is closed', async () => { + const pty = await client.create(); + pty.close(); + + await expect(pty.write('test')).rejects.toThrow('PTY is closed'); + }); + }); + + describe('resize', () => { + it('should resize PTY via WebSocket sendMessage', async () => { + const pty = await client.create(); + + await pty.resize(100, 30); + + expect(mockWebSocketTransport.sendMessage).toHaveBeenCalledWith({ + type: 'pty_resize', + ptyId: 'pty_test', + cols: 100, + rows: 30 + }); + }); + + it('should throw when PTY is closed', async () => { + const pty = await client.create(); + pty.close(); + + await expect(pty.resize(100, 30)).rejects.toThrow('PTY is closed'); + }); + }); + + describe('kill', () => { + it('should kill PTY with default signal', async () => { + const pty = await client.create(); + + // Mock transport.fetch to return success + mockWebSocketTransport.fetch.mockResolvedValue( + new Response('{}', { status: 200 }) + ); + + await pty.kill(); + + expect(mockWebSocketTransport.fetch).toHaveBeenCalledWith( + '/api/pty/pty_test', + expect.objectContaining({ + method: 'DELETE' + }) + ); + }); + + it('should kill PTY with custom signal', async () => { + const pty = await client.create(); + + // Mock transport.fetch to return success + mockWebSocketTransport.fetch.mockResolvedValue( + new Response('{}', { status: 200 }) + ); + + await pty.kill('SIGKILL'); + + expect(mockWebSocketTransport.fetch).toHaveBeenCalledWith( + '/api/pty/pty_test', + expect.objectContaining({ + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ signal: 'SIGKILL' }) + }) + ); + }); + + it('should throw error on HTTP failure', async () => { + const pty = await client.create(); + + // Mock transport.fetch to return error + mockWebSocketTransport.fetch.mockResolvedValue( + new Response('PTY not found', { + status: 404, + statusText: 'Not Found' + }) + ); + + await expect(pty.kill()).rejects.toThrow( + 'PTY kill failed: HTTP 404: PTY not found' + ); + }); + }); + + describe('onData', () => { + it('should register data listener via onStreamEvent', async () => { + const pty = await client.create(); + const callback = vi.fn(); + + pty.onData(callback); + + expect(mockWebSocketTransport.onStreamEvent).toHaveBeenCalledWith( + 'pty_test', + 'pty_data', + callback + ); + }); + + it('should return unsubscribe function', async () => { + const pty = await client.create(); + const callback = vi.fn(); + const mockUnsub = vi.fn(); + mockWebSocketTransport.onStreamEvent.mockReturnValue(mockUnsub); + + const unsub = pty.onData(callback); + unsub(); + + expect(mockUnsub).toHaveBeenCalled(); + }); + }); + + describe('onExit', () => { + it('should register exit listener via onStreamEvent', async () => { + const pty = await client.create(); + const callback = vi.fn(); + + pty.onExit(callback); + + // onStreamEvent is called once in constructor for exited promise, + // and once here for the explicit listener + expect(mockWebSocketTransport.onStreamEvent).toHaveBeenCalledWith( + 'pty_test', + 'pty_exit', + expect.any(Function) + ); + }); + }); + + describe('close', () => { + it('should prevent write operations after close', async () => { + const pty = await client.create(); + + pty.close(); + + await expect(pty.write('test')).rejects.toThrow('PTY is closed'); + }); + + it('should prevent resize operations after close', async () => { + const pty = await client.create(); + + pty.close(); + + await expect(pty.resize(100, 30)).rejects.toThrow('PTY is closed'); + }); + + it('should warn when registering listeners after close', async () => { + const pty = await client.create(); + + pty.close(); + + // These should return no-op functions without throwing + const unsub1 = pty.onData(() => {}); + const unsub2 = pty.onExit(() => {}); + + expect(typeof unsub1).toBe('function'); + expect(typeof unsub2).toBe('function'); + }); + }); + }); + + describe('constructor options', () => { + it('should initialize with minimal options', () => { + const minimalClient = new PtyClient(); + expect(minimalClient).toBeDefined(); + }); + + it('should initialize with full options', () => { + const fullOptionsClient = new PtyClient({ + baseUrl: 'http://custom.com', + port: 8080 + }); + expect(fullOptionsClient).toBeDefined(); + }); + }); + + describe('disconnectPtyTransport', () => { + it('should disconnect the WebSocket transport', async () => { + // Create a PTY to initialize the transport + const mockResponse: PtyCreateResult = { + success: true, + pty: { + id: 'pty_123', + cols: 80, + rows: 24, + command: ['bash'], + cwd: '/home/user', + createdAt: '2023-01-01T00:00:00Z', + state: 'running' + }, + timestamp: '2023-01-01T00:00:00Z' + }; + + mockFetch.mockResolvedValue( + new Response(JSON.stringify(mockResponse), { status: 200 }) + ); + + await client.create(); + + // Disconnect + client.disconnectPtyTransport(); + + expect(mockWebSocketTransport.disconnect).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/sandbox/tests/ws-transport.test.ts b/packages/sandbox/tests/ws-transport.test.ts index 7d66049f..e6263af4 100644 --- a/packages/sandbox/tests/ws-transport.test.ts +++ b/packages/sandbox/tests/ws-transport.test.ts @@ -11,7 +11,7 @@ import { isWSResponse, isWSStreamChunk } from '@repo/shared'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { WebSocketTransport } from '../src/clients/transport'; /** @@ -255,4 +255,91 @@ describe('WebSocketTransport', () => { await expect(transport.fetchStream('/test')).rejects.toThrow(); }); }); + + describe('real-time messaging without connection', () => { + it('should throw when sending message without connection', () => { + const transport = new WebSocketTransport({ + wsUrl: 'ws://localhost:3000/ws' + }); + + expect(() => + transport.sendMessage({ + type: 'pty_input', + ptyId: 'pty_123', + data: 'test' + }) + ).toThrow(/WebSocket not connected/); + }); + + it('should allow stream event listener registration without connection', () => { + const transport = new WebSocketTransport({ + wsUrl: 'ws://localhost:3000/ws' + }); + + // Listeners can be registered before connection + const unsubData = transport.onStreamEvent( + 'pty_123', + 'pty_data', + () => {} + ); + const unsubExit = transport.onStreamEvent( + 'pty_123', + 'pty_exit', + () => {} + ); + + // Should return unsubscribe functions + expect(typeof unsubData).toBe('function'); + expect(typeof unsubExit).toBe('function'); + + // Cleanup should not throw + unsubData(); + unsubExit(); + }); + + it('should handle multiple stream event listeners for same stream', () => { + const transport = new WebSocketTransport({ + wsUrl: 'ws://localhost:3000/ws' + }); + + const callbacks: Array<() => void> = []; + + // Register multiple listeners + for (let i = 0; i < 5; i++) { + callbacks.push( + transport.onStreamEvent('pty_123', 'pty_data', () => {}) + ); + callbacks.push( + transport.onStreamEvent('pty_123', 'pty_exit', () => {}) + ); + } + + // All should be unsubscribable + for (const unsub of callbacks) { + unsub(); + } + }); + }); + + describe('cleanup behavior', () => { + it('should clear stream event listeners on disconnect', () => { + const transport = new WebSocketTransport({ + wsUrl: 'ws://localhost:3000/ws' + }); + + // Register listeners + const dataCallback = vi.fn(); + const exitCallback = vi.fn(); + transport.onStreamEvent('pty_123', 'pty_data', dataCallback); + transport.onStreamEvent('pty_123', 'pty_exit', exitCallback); + + // Disconnect should clean up + transport.disconnect(); + + // Re-registering should work (new listener sets) + const unsub = transport.onStreamEvent('pty_123', 'pty_data', () => {}); + expect(typeof unsub).toBe('function'); + unsub(); + }); + }); }); diff --git a/packages/shared/src/errors/codes.ts b/packages/shared/src/errors/codes.ts index 9dbc12a7..64797c98 100644 --- a/packages/shared/src/errors/codes.ts +++ b/packages/shared/src/errors/codes.ts @@ -108,6 +108,20 @@ export const ErrorCode = { PROCESS_READY_TIMEOUT: 'PROCESS_READY_TIMEOUT', PROCESS_EXITED_BEFORE_READY: 'PROCESS_EXITED_BEFORE_READY', + // PTY Errors (404) + PTY_NOT_FOUND: 'PTY_NOT_FOUND', + + // PTY Errors (409) + PTY_ALREADY_ATTACHED: 'PTY_ALREADY_ATTACHED', + + // PTY Errors (400) + PTY_INVALID_DIMENSIONS: 'PTY_INVALID_DIMENSIONS', + PTY_EXITED: 'PTY_EXITED', + + // PTY Errors (500) + PTY_CREATE_ERROR: 'PTY_CREATE_ERROR', + PTY_OPERATION_ERROR: 'PTY_OPERATION_ERROR', + // Validation Errors (400) VALIDATION_FAILED: 'VALIDATION_FAILED', diff --git a/packages/shared/src/errors/status-map.ts b/packages/shared/src/errors/status-map.ts index 68b4e000..ac06a045 100644 --- a/packages/shared/src/errors/status-map.ts +++ b/packages/shared/src/errors/status-map.ts @@ -13,6 +13,7 @@ export const ERROR_STATUS_MAP: Record = { [ErrorCode.GIT_REPOSITORY_NOT_FOUND]: 404, [ErrorCode.GIT_BRANCH_NOT_FOUND]: 404, [ErrorCode.CONTEXT_NOT_FOUND]: 404, + [ErrorCode.PTY_NOT_FOUND]: 404, // 400 Bad Request [ErrorCode.IS_DIRECTORY]: 400, @@ -27,6 +28,8 @@ export const ERROR_STATUS_MAP: Record = { [ErrorCode.VALIDATION_FAILED]: 400, [ErrorCode.MISSING_CREDENTIALS]: 400, [ErrorCode.INVALID_MOUNT_CONFIG]: 400, + [ErrorCode.PTY_INVALID_DIMENSIONS]: 400, + [ErrorCode.PTY_EXITED]: 400, // 401 Unauthorized [ErrorCode.GIT_AUTH_FAILED]: 401, @@ -43,6 +46,7 @@ export const ERROR_STATUS_MAP: Record = { [ErrorCode.PORT_IN_USE]: 409, [ErrorCode.RESOURCE_BUSY]: 409, [ErrorCode.SESSION_ALREADY_EXISTS]: 409, + [ErrorCode.PTY_ALREADY_ATTACHED]: 409, // 502 Bad Gateway [ErrorCode.SERVICE_NOT_RESPONDING]: 502, @@ -75,6 +79,8 @@ export const ERROR_STATUS_MAP: Record = { [ErrorCode.CODE_EXECUTION_ERROR]: 500, [ErrorCode.BUCKET_MOUNT_ERROR]: 500, [ErrorCode.S3FS_MOUNT_ERROR]: 500, + [ErrorCode.PTY_CREATE_ERROR]: 500, + [ErrorCode.PTY_OPERATION_ERROR]: 500, [ErrorCode.UNKNOWN_ERROR]: 500, [ErrorCode.INTERNAL_ERROR]: 500 }; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 92413a6c..39ba58b2 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -61,6 +61,7 @@ export type { ContextCreateResult, ContextDeleteResult, ContextListResult, + CreatePtyOptions, DeleteFileResult, EnvSetResult, ExecEvent, @@ -104,6 +105,18 @@ export type { // Process management result types ProcessStartResult, ProcessStatus, + PtyCreateResult, + // PTY exit info + PtyExitInfo, + PtyGetResult, + PtyInfo, + PtyInputRequest, + PtyInputResult, + PtyKillResult, + PtyListResult, + PtyResizeRequest, + PtyResizeResult, + PtyState, ReadFileResult, RenameFileResult, // Sandbox configuration options @@ -121,6 +134,7 @@ export type { WriteFileResult } from './types.js'; export { + getPtyExitInfo, isExecResult, isProcess, isProcessStatus, @@ -131,6 +145,8 @@ export type { WSClientMessage, WSError, WSMethod, + WSPtyInput, + WSPtyResize, WSRequest, WSResponse, WSServerMessage, @@ -139,6 +155,8 @@ export type { export { generateRequestId, isWSError, + isWSPtyInput, + isWSPtyResize, isWSRequest, isWSResponse, isWSStreamChunk diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 1a70d9c4..bf02fdaf 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -1110,6 +1110,185 @@ export function isProcessStatus(value: string): value is ProcessStatus { ].includes(value); } +// PTY (Pseudo-Terminal) Types + +/** + * PTY session state + */ +export type PtyState = 'running' | 'exited'; + +/** + * Options for creating a new PTY session + */ +export interface CreatePtyOptions { + /** Terminal width in columns (default: 80) */ + cols?: number; + /** Terminal height in rows (default: 24) */ + rows?: number; + /** Command to run (default: ['bash']) */ + command?: string[]; + /** Working directory (default: /home/user) */ + cwd?: string; + /** Environment variables */ + env?: Record; + /** Session ID to attach PTY to (for session attachment) */ + sessionId?: string; +} + +/** + * Structured exit information for PTY sessions + * Maps exit codes to human-readable signal names (matches Daytona's pattern) + */ +export interface PtyExitInfo { + /** Process exit code */ + exitCode: number; + /** Signal name if killed by signal (e.g., 'SIGKILL', 'SIGTERM') */ + signal?: string; + /** Human-readable exit reason */ + reason: string; +} + +/** + * Get structured exit information from an exit code + * Exit codes > 128 indicate the process was killed by a signal (128 + signal number) + */ +export function getPtyExitInfo(exitCode: number): PtyExitInfo { + // Common signal mappings (128 + signal number) + const signalMap: Record = { + 130: { signal: 'SIGINT', reason: 'Interrupted (Ctrl+C)' }, + 137: { signal: 'SIGKILL', reason: 'Killed' }, + 143: { signal: 'SIGTERM', reason: 'Terminated' }, + 131: { signal: 'SIGQUIT', reason: 'Quit' }, + 134: { signal: 'SIGABRT', reason: 'Aborted' }, + 136: { signal: 'SIGFPE', reason: 'Floating point exception' }, + 139: { signal: 'SIGSEGV', reason: 'Segmentation fault' }, + 141: { signal: 'SIGPIPE', reason: 'Broken pipe' }, + 142: { signal: 'SIGALRM', reason: 'Alarm' }, + 129: { signal: 'SIGHUP', reason: 'Hangup' } + }; + + if (exitCode === 0) { + return { exitCode, reason: 'Exited normally' }; + } + + const signalInfo = signalMap[exitCode]; + if (signalInfo) { + return { exitCode, signal: signalInfo.signal, reason: signalInfo.reason }; + } + + // Unknown signal (exitCode > 128) + if (exitCode > 128) { + const signalNum = exitCode - 128; + return { + exitCode, + signal: `SIG${signalNum}`, + reason: `Killed by signal ${signalNum}` + }; + } + + // Non-zero exit without signal + return { exitCode, reason: `Exited with code ${exitCode}` }; +} + +/** + * PTY session information + */ +export interface PtyInfo { + /** Unique PTY identifier */ + id: string; + /** Terminal width */ + cols: number; + /** Terminal height */ + rows: number; + /** Command running in PTY */ + command: string[]; + /** Working directory */ + cwd: string; + /** When the PTY was created */ + createdAt: string; + /** Current state */ + state: PtyState; + /** Exit code if exited */ + exitCode?: number; + /** Structured exit information (populated when state is 'exited') */ + exitInfo?: PtyExitInfo; + /** Session ID this PTY is attached to (if any) */ + sessionId?: string; +} + +/** + * Request to send input to PTY + */ +export interface PtyInputRequest { + data: string; +} + +/** + * Result from sending input to PTY + */ +export interface PtyInputResult { + success: boolean; + ptyId: string; + error?: string; + timestamp: string; +} + +/** + * Request to resize PTY + */ +export interface PtyResizeRequest { + cols: number; + rows: number; +} + +/** + * Result from creating a PTY + */ +export interface PtyCreateResult { + success: boolean; + pty: PtyInfo; + timestamp: string; +} + +/** + * Result from listing PTYs + */ +export interface PtyListResult { + success: boolean; + ptys: PtyInfo[]; + timestamp: string; +} + +/** + * Result from getting a PTY + */ +export interface PtyGetResult { + success: boolean; + pty: PtyInfo; + timestamp: string; +} + +/** + * Result from killing a PTY + */ +export interface PtyKillResult { + success: boolean; + ptyId: string; + timestamp: string; +} + +/** + * Result from resizing a PTY + */ +export interface PtyResizeResult { + success: boolean; + ptyId: string; + cols: number; + rows: number; + error?: string; + timestamp: string; +} + export type { ChartData, CodeContext, diff --git a/packages/shared/src/ws-types.ts b/packages/shared/src/ws-types.ts index 9c8e8506..0d933cc7 100644 --- a/packages/shared/src/ws-types.ts +++ b/packages/shared/src/ws-types.ts @@ -105,10 +105,53 @@ export interface WSError { */ export type WSServerMessage = WSResponse | WSStreamChunk | WSError; +/** + * PTY input message - send keystrokes to PTY (fire-and-forget) + */ +export interface WSPtyInput { + type: 'pty_input'; + ptyId: string; + data: string; +} + +/** + * PTY resize message - resize terminal (fire-and-forget) + */ +export interface WSPtyResize { + type: 'pty_resize'; + ptyId: string; + cols: number; + rows: number; +} + +/** + * Type guard for WSPtyInput + */ +export function isWSPtyInput(msg: unknown): msg is WSPtyInput { + return ( + typeof msg === 'object' && + msg !== null && + 'type' in msg && + (msg as WSPtyInput).type === 'pty_input' + ); +} + +/** + * Type guard for WSPtyResize + */ +export function isWSPtyResize(msg: unknown): msg is WSPtyResize { + return ( + typeof msg === 'object' && + msg !== null && + 'type' in msg && + (msg as WSPtyResize).type === 'pty_resize' + ); +} + /** * Union type for all WebSocket messages from client to server */ -export type WSClientMessage = WSRequest; +export type WSClientMessage = WSRequest | WSPtyInput | WSPtyResize; /** * Type guard for WSRequest diff --git a/sites/sandbox/package.json b/sites/sandbox/package.json index f126bec5..51b6fc4a 100644 --- a/sites/sandbox/package.json +++ b/sites/sandbox/package.json @@ -14,7 +14,7 @@ "dependencies": { "@astrojs/check": "^0.9.5", "@astrojs/react": "^4.4.2", - "@cloudflare/vite-plugin": "^1.15.2", + "@cloudflare/vite-plugin": "^1.20.1", "@tailwindcss/vite": "^4.1.17", "astro": "^5.16.0", "clsx": "^2.1.1", diff --git a/tests/e2e/pty-workflow.test.ts b/tests/e2e/pty-workflow.test.ts new file mode 100644 index 00000000..ee4c500d --- /dev/null +++ b/tests/e2e/pty-workflow.test.ts @@ -0,0 +1,685 @@ +import { beforeAll, describe, expect, test } from 'vitest'; +import { + createUniqueSession, + getSharedSandbox +} from './helpers/global-sandbox'; + +/** + * PTY (Pseudo-Terminal) Workflow Tests + * + * Tests the PTY API endpoints for interactive terminal sessions: + * - Create PTY session + * - List PTY sessions + * - Get PTY info + * - Send input via HTTP (fallback) + * - Resize PTY via HTTP (fallback) + * - Kill PTY session + * + * Note: Real-time input/output via WebSocket is tested separately. + * These tests focus on the HTTP API for PTY management. + */ +describe('PTY Workflow', () => { + let workerUrl: string; + let headers: Record; + + beforeAll(async () => { + const sandbox = await getSharedSandbox(); + workerUrl = sandbox.workerUrl; + headers = sandbox.createHeaders(createUniqueSession()); + }, 120000); + + test('PTY sanity check - container has PTY support', async () => { + // Verify /dev/ptmx and /dev/pts exist in the container + const checkResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: + 'ls -l /dev/ptmx && ls -ld /dev/pts && mount | grep devpts || echo "devpts not mounted"' + }) + }); + + expect(checkResponse.status).toBe(200); + const checkData = (await checkResponse.json()) as { + success: boolean; + stdout: string; + stderr: string; + }; + + console.log('[PTY Sanity Check] stdout:', checkData.stdout); + console.log('[PTY Sanity Check] stderr:', checkData.stderr); + + // /dev/ptmx should exist for PTY allocation + expect(checkData.stdout).toContain('/dev/ptmx'); + }, 30000); + + test('should create a PTY session', async () => { + // Use /bin/sh and /tmp for reliable PTY creation + const response = await fetch(`${workerUrl}/api/pty`, { + method: 'POST', + headers, + body: JSON.stringify({ command: ['/bin/sh'], cwd: '/tmp' }) + }); + + // Log response for debugging if it fails + if (response.status !== 200) { + const errorText = await response.text(); + console.error( + '[PTY Create] Failed with status:', + response.status, + 'body:', + errorText + ); + } + + expect(response.status).toBe(200); + const data = (await response.json()) as { + success: boolean; + pty: { + id: string; + cols: number; + rows: number; + command: string[]; + state: string; + }; + error?: string; + }; + + console.log('[PTY Create] Response:', JSON.stringify(data, null, 2)); + + expect(data.success).toBe(true); + expect(data.pty.id).toMatch(/^pty_/); + expect(data.pty.cols).toBe(80); + expect(data.pty.rows).toBe(24); + expect(data.pty.command).toEqual(['/bin/sh']); + expect(data.pty.state).toBe('running'); + + // Cleanup + await fetch(`${workerUrl}/api/pty/${data.pty.id}`, { + method: 'DELETE', + headers + }); + }, 90000); + + test('should create a PTY session with custom options', async () => { + const response = await fetch(`${workerUrl}/api/pty`, { + method: 'POST', + headers, + body: JSON.stringify({ + cols: 120, + rows: 40, + command: ['sh'], + cwd: '/tmp' + }) + }); + + expect(response.status).toBe(200); + const data = (await response.json()) as { + success: boolean; + pty: { + id: string; + cols: number; + rows: number; + command: string[]; + cwd: string; + }; + }; + + expect(data.success).toBe(true); + expect(data.pty.cols).toBe(120); + expect(data.pty.rows).toBe(40); + expect(data.pty.command).toEqual(['sh']); + expect(data.pty.cwd).toBe('/tmp'); + + // Cleanup + await fetch(`${workerUrl}/api/pty/${data.pty.id}`, { + method: 'DELETE', + headers + }); + }, 90000); + + test('should list all PTY sessions', async () => { + // Create two PTYs with explicit shell command + const pty1Response = await fetch(`${workerUrl}/api/pty`, { + method: 'POST', + headers, + body: JSON.stringify({ + cols: 80, + rows: 24, + command: ['/bin/sh'], + cwd: '/tmp' + }) + }); + const pty1 = (await pty1Response.json()) as { pty: { id: string } }; + + const pty2Response = await fetch(`${workerUrl}/api/pty`, { + method: 'POST', + headers, + body: JSON.stringify({ + cols: 100, + rows: 30, + command: ['/bin/sh'], + cwd: '/tmp' + }) + }); + const pty2 = (await pty2Response.json()) as { pty: { id: string } }; + + // List all PTYs + const listResponse = await fetch(`${workerUrl}/api/pty`, { + method: 'GET', + headers + }); + + expect(listResponse.status).toBe(200); + const listData = (await listResponse.json()) as { + success: boolean; + ptys: Array<{ id: string }>; + }; + + expect(listData.success).toBe(true); + expect(listData.ptys.length).toBeGreaterThanOrEqual(2); + expect(listData.ptys.some((p) => p.id === pty1.pty.id)).toBe(true); + expect(listData.ptys.some((p) => p.id === pty2.pty.id)).toBe(true); + + // Cleanup + await fetch(`${workerUrl}/api/pty/${pty1.pty.id}`, { + method: 'DELETE', + headers + }); + await fetch(`${workerUrl}/api/pty/${pty2.pty.id}`, { + method: 'DELETE', + headers + }); + }, 90000); + + test('should get PTY info by ID', async () => { + // Create a PTY with explicit shell command + const createResponse = await fetch(`${workerUrl}/api/pty`, { + method: 'POST', + headers, + body: JSON.stringify({ + cols: 100, + rows: 50, + command: ['/bin/sh'], + cwd: '/tmp' + }) + }); + const createData = (await createResponse.json()) as { + pty: { id: string }; + }; + + // Get PTY info + const getResponse = await fetch( + `${workerUrl}/api/pty/${createData.pty.id}`, + { + method: 'GET', + headers + } + ); + + expect(getResponse.status).toBe(200); + const getData = (await getResponse.json()) as { + success: boolean; + pty: { id: string; cols: number; rows: number }; + }; + + expect(getData.success).toBe(true); + expect(getData.pty.id).toBe(createData.pty.id); + expect(getData.pty.cols).toBe(100); + expect(getData.pty.rows).toBe(50); + + // Cleanup + await fetch(`${workerUrl}/api/pty/${createData.pty.id}`, { + method: 'DELETE', + headers + }); + }, 90000); + + test('should return error for nonexistent PTY', async () => { + const response = await fetch(`${workerUrl}/api/pty/pty_nonexistent_12345`, { + method: 'GET', + headers + }); + + expect(response.status).toBe(404); + const data = (await response.json()) as { message: string }; + expect(data.message).toMatch(/not found/i); + }, 90000); + + test('should resize PTY via HTTP endpoint', async () => { + // Create a PTY with explicit shell command and working directory + const createResponse = await fetch(`${workerUrl}/api/pty`, { + method: 'POST', + headers, + body: JSON.stringify({ + cols: 80, + rows: 24, + command: ['/bin/sh'], + cwd: '/tmp' + }) + }); + const createData = (await createResponse.json()) as { + pty: { id: string; state: string; exitCode?: number }; + }; + console.log( + '[Test] PTY created:', + createData.pty.id, + 'state:', + createData.pty.state, + 'exitCode:', + createData.pty.exitCode + ); + + // Small delay to let PTY initialize + await new Promise((r) => setTimeout(r, 100)); + + // Resize via HTTP + const resizeResponse = await fetch( + `${workerUrl}/api/pty/${createData.pty.id}/resize`, + { + method: 'POST', + headers, + body: JSON.stringify({ cols: 120, rows: 40 }) + } + ); + + expect(resizeResponse.status).toBe(200); + const resizeData = (await resizeResponse.json()) as { + success: boolean; + cols: number; + rows: number; + }; + + expect(resizeData.success).toBe(true); + expect(resizeData.cols).toBe(120); + expect(resizeData.rows).toBe(40); + + // Verify via get + const getResponse = await fetch( + `${workerUrl}/api/pty/${createData.pty.id}`, + { + method: 'GET', + headers + } + ); + const getData = (await getResponse.json()) as { + pty: { cols: number; rows: number }; + }; + + expect(getData.pty.cols).toBe(120); + expect(getData.pty.rows).toBe(40); + + // Cleanup + await fetch(`${workerUrl}/api/pty/${createData.pty.id}`, { + method: 'DELETE', + headers + }); + }, 90000); + + test('should send input via HTTP endpoint', async () => { + // Create a PTY with explicit shell command and working directory + const createResponse = await fetch(`${workerUrl}/api/pty`, { + method: 'POST', + headers, + body: JSON.stringify({ command: ['/bin/sh'], cwd: '/tmp' }) + }); + const createData = (await createResponse.json()) as { + pty: { id: string }; + }; + + // Send input via HTTP (fire-and-forget, just verify it doesn't error) + const inputResponse = await fetch( + `${workerUrl}/api/pty/${createData.pty.id}/input`, + { + method: 'POST', + headers, + body: JSON.stringify({ data: 'echo hello\n' }) + } + ); + + expect(inputResponse.status).toBe(200); + const inputData = (await inputResponse.json()) as { success: boolean }; + expect(inputData.success).toBe(true); + + // Wait a bit for the command to execute + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Cleanup + await fetch(`${workerUrl}/api/pty/${createData.pty.id}`, { + method: 'DELETE', + headers + }); + }, 90000); + + test('should kill PTY session', async () => { + // Create a PTY that will exit quickly when killed + // Use 'cat' which exits immediately when stdin closes + const createResponse = await fetch(`${workerUrl}/api/pty`, { + method: 'POST', + headers, + body: JSON.stringify({ command: ['/bin/cat'], cwd: '/tmp' }) + }); + const createData = (await createResponse.json()) as { + pty: { id: string; state: string }; + }; + + console.log( + '[Kill Test] Created PTY:', + createData.pty.id, + 'state:', + createData.pty.state + ); + + // Kill the PTY with SIGKILL for immediate termination + const killResponse = await fetch( + `${workerUrl}/api/pty/${createData.pty.id}`, + { + method: 'DELETE', + headers, + body: JSON.stringify({ signal: 'SIGKILL' }) + } + ); + + expect(killResponse.status).toBe(200); + const killData = (await killResponse.json()) as { + success: boolean; + ptyId: string; + }; + + console.log('[Kill Test] Kill response:', killData); + + expect(killData.success).toBe(true); + expect(killData.ptyId).toBe(createData.pty.id); + + // Wait for process to exit - poll with longer intervals + let getData: { pty: { state: string; exitCode?: number } } | null = null; + for (let i = 0; i < 30; i++) { + await new Promise((resolve) => setTimeout(resolve, 300)); + + const getResponse = await fetch( + `${workerUrl}/api/pty/${createData.pty.id}`, + { + method: 'GET', + headers + } + ); + + getData = (await getResponse.json()) as { + pty: { state: string; exitCode?: number }; + }; + console.log( + `[Kill Test] Poll ${i + 1}: state=${getData.pty.state}, exitCode=${getData.pty.exitCode}` + ); + if (getData.pty.state === 'exited') break; + } + + // Verify PTY state is exited + expect(getData?.pty.state).toBe('exited'); + }, 90000); + + test('should stream PTY output via SSE', async () => { + // Create a PTY with explicit shell command and working directory + const createResponse = await fetch(`${workerUrl}/api/pty`, { + method: 'POST', + headers, + body: JSON.stringify({ command: ['/bin/sh'], cwd: '/tmp' }) + }); + const createData = (await createResponse.json()) as { + pty: { id: string }; + }; + + // Open SSE stream + const streamResponse = await fetch( + `${workerUrl}/api/pty/${createData.pty.id}/stream`, + { + method: 'GET', + headers + } + ); + + expect(streamResponse.status).toBe(200); + expect(streamResponse.headers.get('content-type')).toBe( + 'text/event-stream' + ); + + // Send a command + await fetch(`${workerUrl}/api/pty/${createData.pty.id}/input`, { + method: 'POST', + headers, + body: JSON.stringify({ data: 'echo "pty-test-output"\n' }) + }); + + // Read some events from the stream + const reader = streamResponse.body?.getReader(); + const decoder = new TextDecoder(); + const events: string[] = []; + + if (reader) { + const timeout = Date.now() + 5000; + + while (Date.now() < timeout && events.length < 5) { + const { value, done } = await reader.read(); + if (done) break; + + if (value) { + const chunk = decoder.decode(value); + events.push(chunk); + + // Stop if we see our test output + if (chunk.includes('pty-test-output')) { + break; + } + } + } + reader.cancel(); + } + + // Should have received some data + expect(events.length).toBeGreaterThan(0); + + // Cleanup + await fetch(`${workerUrl}/api/pty/${createData.pty.id}`, { + method: 'DELETE', + headers + }); + }, 90000); + + test('should attach PTY to existing session', async () => { + // First create a session by running a command + const sessionId = `pty-attach-test-${Date.now()}`; + const sessionHeaders = { + ...headers, + 'X-Session-Id': sessionId + }; + + // Run a command to initialize the session + await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers: sessionHeaders, + body: JSON.stringify({ command: 'cd /tmp && export MY_VAR=hello' }) + }); + + // Attach PTY to session with explicit shell command and cwd + const attachResponse = await fetch( + `${workerUrl}/api/pty/attach/${sessionId}`, + { + method: 'POST', + headers: sessionHeaders, + body: JSON.stringify({ + cols: 80, + rows: 24, + command: ['/bin/sh'], + cwd: '/tmp' + }) + } + ); + + // Log error details if attach fails + if (attachResponse.status !== 200) { + const errorBody = await attachResponse.clone().text(); + console.error( + '[Attach Test] Failed with status:', + attachResponse.status, + 'body:', + errorBody + ); + } + + expect(attachResponse.status).toBe(200); + const attachData = (await attachResponse.json()) as { + success: boolean; + pty: { id: string; sessionId: string }; + }; + + expect(attachData.success).toBe(true); + expect(attachData.pty.sessionId).toBe(sessionId); + + // Cleanup + await fetch(`${workerUrl}/api/pty/${attachData.pty.id}`, { + method: 'DELETE', + headers: sessionHeaders + }); + }, 90000); + + test('should prevent double PTY attachment to same session', async () => { + // Create a session and attach first PTY + const sessionId = `pty-double-attach-test-${Date.now()}`; + const sessionHeaders = { + ...headers, + 'X-Session-Id': sessionId + }; + + // Initialize session + await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers: sessionHeaders, + body: JSON.stringify({ command: 'echo init' }) + }); + + // First attachment should succeed + const firstAttachResponse = await fetch( + `${workerUrl}/api/pty/attach/${sessionId}`, + { + method: 'POST', + headers: sessionHeaders, + body: JSON.stringify({ command: ['/bin/sh'], cwd: '/tmp' }) + } + ); + expect(firstAttachResponse.status).toBe(200); + const firstAttachData = (await firstAttachResponse.json()) as { + success: boolean; + pty: { id: string }; + }; + expect(firstAttachData.success).toBe(true); + + // Second attachment should fail + const secondAttachResponse = await fetch( + `${workerUrl}/api/pty/attach/${sessionId}`, + { + method: 'POST', + headers: sessionHeaders, + body: JSON.stringify({ command: ['/bin/sh'], cwd: '/tmp' }) + } + ); + expect(secondAttachResponse.status).toBe(409); + const secondAttachData = (await secondAttachResponse.json()) as { + message: string; + }; + expect(secondAttachData.message).toMatch(/already has active PTY/i); + + // Cleanup + await fetch(`${workerUrl}/api/pty/${firstAttachData.pty.id}`, { + method: 'DELETE', + headers: sessionHeaders + }); + }, 90000); + + // TODO: This test requires Docker image 0.7.0+ with dimension validation + test.skip('should reject invalid dimension values on create', async () => { + // Test cols below minimum - validation rejects cols < 1 + const response1 = await fetch(`${workerUrl}/api/pty`, { + method: 'POST', + headers, + body: JSON.stringify({ command: ['/bin/sh'], cols: 0, rows: 24 }) + }); + expect(response1.status).toBe(500); + const data1 = (await response1.json()) as { error: string }; + expect(data1.error).toMatch(/Invalid cols.*Must be between 1 and/i); + + // Test cols above maximum - validation rejects cols > 1000 + const response2 = await fetch(`${workerUrl}/api/pty`, { + method: 'POST', + headers, + body: JSON.stringify({ command: ['/bin/sh'], cols: 1001, rows: 24 }) + }); + expect(response2.status).toBe(500); + const data2 = (await response2.json()) as { error: string }; + expect(data2.error).toMatch(/Invalid cols.*Must be between 1 and/i); + + // Test rows below minimum - validation rejects rows < 1 + const response3 = await fetch(`${workerUrl}/api/pty`, { + method: 'POST', + headers, + body: JSON.stringify({ command: ['/bin/sh'], cols: 80, rows: 0 }) + }); + expect(response3.status).toBe(500); + const data3 = (await response3.json()) as { error: string }; + expect(data3.error).toMatch(/Invalid rows.*Must be between 1 and/i); + + // Test rows above maximum - validation rejects rows > 1000 + const response4 = await fetch(`${workerUrl}/api/pty`, { + method: 'POST', + headers, + body: JSON.stringify({ command: ['/bin/sh'], cols: 80, rows: 1001 }) + }); + expect(response4.status).toBe(500); + const data4 = (await response4.json()) as { error: string }; + expect(data4.error).toMatch(/Invalid rows.*Must be between 1 and/i); + }, 90000); + + // TODO: This test requires Docker image 0.7.0+ with dimension validation + test.skip('should reject invalid dimension values on resize', async () => { + // Create a valid PTY first + const createResponse = await fetch(`${workerUrl}/api/pty`, { + method: 'POST', + headers, + body: JSON.stringify({ command: ['/bin/sh'], cwd: '/tmp' }) + }); + expect(createResponse.status).toBe(200); + const createData = (await createResponse.json()) as { + pty: { id: string }; + }; + + // Test resize with cols below minimum - validation rejects cols < 1 + const response1 = await fetch( + `${workerUrl}/api/pty/${createData.pty.id}/resize`, + { + method: 'POST', + headers, + body: JSON.stringify({ cols: 0, rows: 24 }) + } + ); + expect(response1.status).toBe(500); + const data1 = (await response1.json()) as { error: string }; + expect(data1.error).toMatch(/Invalid dimensions.*Must be between 1 and/i); + + // Test resize with cols above maximum - validation rejects cols > 1000 + const response2 = await fetch( + `${workerUrl}/api/pty/${createData.pty.id}/resize`, + { + method: 'POST', + headers, + body: JSON.stringify({ cols: 1001, rows: 24 }) + } + ); + expect(response2.status).toBe(500); + const data2 = (await response2.json()) as { error: string }; + expect(data2.error).toMatch(/Invalid dimensions.*Must be between 1 and/i); + + // Cleanup + await fetch(`${workerUrl}/api/pty/${createData.pty.id}`, { + method: 'DELETE', + headers + }); + }, 90000); +}); diff --git a/tests/e2e/test-worker/index.ts b/tests/e2e/test-worker/index.ts index fb24b273..15dbe548 100644 --- a/tests/e2e/test-worker/index.ts +++ b/tests/e2e/test-worker/index.ts @@ -823,6 +823,143 @@ console.log('Terminal server on port ' + port); }); } + // PTY operations - use sandbox.fetch() to forward to container HTTP API + + // PTY create + if (url.pathname === '/api/pty' && request.method === 'POST') { + const ptyResponse = await sandbox.fetch( + new Request('http://container/api/pty', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }) + ); + const data = await ptyResponse.json(); + return new Response(JSON.stringify(data), { + status: ptyResponse.status, + headers: { 'Content-Type': 'application/json' } + }); + } + + // PTY list + if (url.pathname === '/api/pty' && request.method === 'GET') { + const ptyResponse = await sandbox.fetch( + new Request('http://container/api/pty', { method: 'GET' }) + ); + const data = await ptyResponse.json(); + return new Response(JSON.stringify(data), { + status: ptyResponse.status, + headers: { 'Content-Type': 'application/json' } + }); + } + + // PTY attach to session + if ( + url.pathname.startsWith('/api/pty/attach/') && + request.method === 'POST' + ) { + const attachSessionId = url.pathname.split('/')[4]; + const ptyResponse = await sandbox.fetch( + new Request(`http://container/api/pty/attach/${attachSessionId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }) + ); + const data = await ptyResponse.json(); + return new Response(JSON.stringify(data), { + status: ptyResponse.status, + headers: { 'Content-Type': 'application/json' } + }); + } + + // PTY routes with ID + if (url.pathname.startsWith('/api/pty/')) { + const pathParts = url.pathname.split('/'); + const ptyId = pathParts[3]; + const action = pathParts[4]; + + // GET /api/pty/:id - get PTY info + if (!action && request.method === 'GET') { + const ptyResponse = await sandbox.fetch( + new Request(`http://container/api/pty/${ptyId}`, { method: 'GET' }) + ); + const data = await ptyResponse.json(); + return new Response(JSON.stringify(data), { + status: ptyResponse.status, + headers: { 'Content-Type': 'application/json' } + }); + } + + // DELETE /api/pty/:id - kill PTY + if (!action && request.method === 'DELETE') { + const ptyResponse = await sandbox.fetch( + new Request(`http://container/api/pty/${ptyId}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: body?.signal + ? JSON.stringify({ signal: body.signal }) + : undefined + }) + ); + const data = await ptyResponse.json(); + return new Response(JSON.stringify(data), { + status: ptyResponse.status, + headers: { 'Content-Type': 'application/json' } + }); + } + + // POST /api/pty/:id/input - send input + if (action === 'input' && request.method === 'POST') { + const ptyResponse = await sandbox.fetch( + new Request(`http://container/api/pty/${ptyId}/input`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data: body.data }) + }) + ); + const data = await ptyResponse.json(); + return new Response(JSON.stringify(data), { + status: ptyResponse.status, + headers: { 'Content-Type': 'application/json' } + }); + } + + // POST /api/pty/:id/resize - resize PTY + if (action === 'resize' && request.method === 'POST') { + const ptyResponse = await sandbox.fetch( + new Request(`http://container/api/pty/${ptyId}/resize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cols: body.cols, rows: body.rows }) + }) + ); + const data = await ptyResponse.json(); + return new Response(JSON.stringify(data), { + status: ptyResponse.status, + headers: { 'Content-Type': 'application/json' } + }); + } + + // GET /api/pty/:id/stream - SSE stream + if (action === 'stream' && request.method === 'GET') { + const ptyResponse = await sandbox.fetch( + new Request(`http://container/api/pty/${ptyId}/stream`, { + method: 'GET', + headers: { Accept: 'text/event-stream' } + }) + ); + return new Response(ptyResponse.body, { + status: ptyResponse.status, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive' + } + }); + } + } + return new Response('Not found', { status: 404 }); } catch (error) { return new Response( diff --git a/tests/e2e/websocket-connect.test.ts b/tests/e2e/websocket-connect.test.ts index 4d342977..27102501 100644 --- a/tests/e2e/websocket-connect.test.ts +++ b/tests/e2e/websocket-connect.test.ts @@ -5,6 +5,53 @@ import { getSharedSandbox } from './helpers/global-sandbox'; +/** + * Helper to wait for WebSocket server to be ready with retries + */ +async function waitForWebSocketServer( + wsUrl: string, + sandboxId: string, + maxRetries = 5, + retryDelay = 1000 +): Promise { + for (let i = 0; i < maxRetries; i++) { + try { + const ws = new WebSocket(wsUrl, { + headers: { 'X-Sandbox-Id': sandboxId } + }); + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + ws.close(); + reject(new Error('Connection timeout')); + }, 5000); + + ws.on('open', () => { + clearTimeout(timeout); + ws.close(); + resolve(); + }); + + ws.on('error', (err) => { + clearTimeout(timeout); + reject(err); + }); + }); + + // Server is ready + return; + } catch { + if (i < maxRetries - 1) { + // Wait before retry + await new Promise((r) => setTimeout(r, retryDelay)); + } + } + } + throw new Error( + `WebSocket server not ready after ${maxRetries} retries at ${wsUrl}` + ); +} + /** * WebSocket Connection Tests * @@ -19,11 +66,15 @@ describe('WebSocket Connections', () => { workerUrl = sandbox.workerUrl; sandboxId = sandbox.sandboxId; - // Initialize sandbox (container echo server is built-in) + // Initialize sandbox (starts echo server on port 8080) await fetch(`${workerUrl}/api/init`, { method: 'POST', headers: { 'X-Sandbox-Id': sandboxId } }); + + // Wait for WebSocket echo server to be ready + const wsUrl = `${workerUrl.replace(/^http/, 'ws')}/ws/echo`; + await waitForWebSocketServer(wsUrl, sandboxId); }, 120000); test('should establish WebSocket connection and echo messages', async () => {