From b5fa46f6beb982f335bdb87ba4a9f35af1e7bb6a Mon Sep 17 00:00:00 2001 From: Ajax Davis Date: Wed, 14 Jan 2026 17:13:58 +1000 Subject: [PATCH] fix: sprites-exec tool to use correct API format - Use query parameters instead of JSON body for cmd - Parse command string into repeatable cmd params - Handle binary response format (stdout=0x01, stderr=0x02, exit=0x03) - Add shell command documentation to README --- .../tools/official/sprites-exec/README.md | 17 ++ .../tools/official/sprites-exec/package.json | 2 +- .../tools/official/sprites-exec/src/index.ts | 169 ++++++++++++++++-- 3 files changed, 169 insertions(+), 19 deletions(-) diff --git a/packages/tools/official/sprites-exec/README.md b/packages/tools/official/sprites-exec/README.md index 2046bbe..f6f72b9 100644 --- a/packages/tools/official/sprites-exec/README.md +++ b/packages/tools/official/sprites-exec/README.md @@ -37,6 +37,23 @@ const pythonResult = await spritesExecTool.execute({ cmd: 'python3', stdin: 'print("Hello from stdin!")' }); + +// For shell features (pipes, redirects, etc), use bash -c +const shellResult = await spritesExecTool.execute({ + name: 'my-sandbox', + cmd: 'bash -c "echo hello > /tmp/test.txt && cat /tmp/test.txt"' +}); +``` + +## Shell Commands + +Commands are executed directly (like `exec.Command` in Go), not through a shell. This means shell operators like `|`, `>`, `>>`, `&&` won't work directly. + +For shell features, wrap your command with `bash -c`: +```typescript +// Won't work: cmd: 'echo hello > file.txt' +// Use instead: +cmd: 'bash -c "echo hello > file.txt"' ``` ## Input Parameters diff --git a/packages/tools/official/sprites-exec/package.json b/packages/tools/official/sprites-exec/package.json index 0f8339f..2628486 100644 --- a/packages/tools/official/sprites-exec/package.json +++ b/packages/tools/official/sprites-exec/package.json @@ -1,6 +1,6 @@ { "name": "@tpmjs/tools-sprites-exec", - "version": "0.1.2", + "version": "0.1.3", "description": "Execute a command inside a sprite and return the output with exit code", "type": "module", "keywords": [ diff --git a/packages/tools/official/sprites-exec/src/index.ts b/packages/tools/official/sprites-exec/src/index.ts index b12bf85..08208d9 100644 --- a/packages/tools/official/sprites-exec/src/index.ts +++ b/packages/tools/official/sprites-exec/src/index.ts @@ -10,6 +10,12 @@ import { jsonSchema, tool } from 'ai'; const SPRITES_API_BASE = 'https://api.sprites.dev/v1'; +// Binary stream IDs from Sprites API +const STREAM_STDIN = 0x00; +const STREAM_STDOUT = 0x01; +const STREAM_STDERR = 0x02; +const STREAM_EXIT = 0x03; + export interface ExecResult { exitCode: number; stdout: string; @@ -34,6 +40,122 @@ function getSpritesToken(): string { return token; } +/** + * Parse a command string into command and arguments. + * Handles quoted strings and escapes. + */ +function parseCommand(cmdString: string): string[] { + const args: string[] = []; + let current = ''; + let inSingleQuote = false; + let inDoubleQuote = false; + let escaped = false; + + for (let i = 0; i < cmdString.length; i++) { + const char = cmdString[i]; + + if (escaped) { + current += char; + escaped = false; + continue; + } + + if (char === '\\' && !inSingleQuote) { + escaped = true; + continue; + } + + if (char === "'" && !inDoubleQuote) { + inSingleQuote = !inSingleQuote; + continue; + } + + if (char === '"' && !inSingleQuote) { + inDoubleQuote = !inDoubleQuote; + continue; + } + + if (char === ' ' && !inSingleQuote && !inDoubleQuote) { + if (current.length > 0) { + args.push(current); + current = ''; + } + continue; + } + + current += char; + } + + if (current.length > 0) { + args.push(current); + } + + return args; +} + +/** + * Parse the binary response from Sprites API. + * Format: [stream_id: 1 byte][payload: rest until next stream_id or end] + */ +function parseBinaryResponse(buffer: Uint8Array): { + stdout: string; + stderr: string; + exitCode: number; +} { + let stdout = ''; + let stderr = ''; + let exitCode = 0; + + const decoder = new TextDecoder(); + let i = 0; + + while (i < buffer.length) { + const streamId = buffer[i]; + i++; + + if (streamId === STREAM_EXIT) { + // Exit code is a single byte + if (i < buffer.length) { + exitCode = buffer[i] as number; + i++; + } + continue; + } + + // Find the next stream marker or end of buffer + let end = i; + while (end < buffer.length) { + const nextByte = buffer[end] as number; + // Check if this looks like a stream ID (0x00-0x03) at a boundary + // We detect boundaries by looking for stream IDs that make sense + if (nextByte <= STREAM_EXIT) { + // Look ahead to see if this is really a stream marker + // Stream markers are followed by data or another marker + break; + } + end++; + } + + const payload = decoder.decode(buffer.slice(i, end)); + + switch (streamId) { + case STREAM_STDIN: + // Ignore stdin echo + break; + case STREAM_STDOUT: + stdout += payload; + break; + case STREAM_STDERR: + stderr += payload; + break; + } + + i = end; + } + + return { stdout, stderr, exitCode }; +} + export const spritesExecTool = tool({ description: 'Execute a command inside a sprite and return the output. Supports stdin input for interactive commands. Returns exit code, stdout, stderr, and execution duration.', @@ -72,24 +194,37 @@ export const spritesExecTool = tool({ const timeout = timeoutMs || 60000; const startTime = Date.now(); + // Parse command into parts + const cmdParts = parseCommand(cmd); + if (cmdParts.length === 0) { + throw new Error('Command cannot be empty'); + } + + // Build URL with repeatable cmd query parameters + // API expects: ?cmd=echo&cmd=hello&cmd=world for "echo hello world" + const url = new URL(`${SPRITES_API_BASE}/sprites/${encodeURIComponent(name)}/exec`); + for (const part of cmdParts) { + url.searchParams.append('cmd', part); + } + + // Add stdin flag if stdin is provided + if (stdin) { + url.searchParams.set('stdin', 'true'); + } + let response: Response; try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); - const body: Record = { cmd }; - if (stdin) { - body.stdin = stdin; - } - - response = await fetch(`${SPRITES_API_BASE}/sprites/${encodeURIComponent(name)}/exec`, { + response = await fetch(url.toString(), { method: 'POST', headers: { Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', 'User-Agent': 'TPMJS/1.0', + ...(stdin ? { 'Content-Type': 'application/octet-stream' } : {}), }, - body: JSON.stringify(body), + body: stdin || undefined, signal: controller.signal, }); @@ -119,18 +254,16 @@ export const spritesExecTool = tool({ ); } - let data: Record; - try { - data = (await response.json()) as Record; - } catch { - throw new Error('Failed to parse response from Sprites API'); - } + // Parse binary response + const arrayBuffer = await response.arrayBuffer(); + const buffer = new Uint8Array(arrayBuffer); + const { stdout, stderr, exitCode } = parseBinaryResponse(buffer); return { - exitCode: (data.exitCode as number) ?? (data.exit_code as number) ?? 0, - stdout: (data.stdout as string) || '', - stderr: (data.stderr as string) || '', - duration: (data.duration as number) || duration, + exitCode, + stdout, + stderr, + duration, }; }, });