From 2e981b506391bf4acf124a30fa70953d477f44bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=95=BE=E8=A5=BF=E4=BA=9A=20hIE?= <152872372+lacia-hIE@users.noreply.github.com> Date: Wed, 29 Oct 2025 17:17:11 +0800 Subject: [PATCH 1/6] fix: Handle encoding parameter correctly in writeFile and readFile methods --- .../src/services/file-service.ts | 51 ++++++++++++++----- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/packages/sandbox-container/src/services/file-service.ts b/packages/sandbox-container/src/services/file-service.ts index fef3c41f..d90209d9 100644 --- a/packages/sandbox-container/src/services/file-service.ts +++ b/packages/sandbox-container/src/services/file-service.ts @@ -170,17 +170,35 @@ export class FileService implements FileSystemOperations { // 5. Determine if file is binary based on MIME type // Text MIME types: text/*, application/json, application/xml, application/javascript, etc. - const isBinary = !mimeType.startsWith('text/') && - !mimeType.includes('json') && - !mimeType.includes('xml') && - !mimeType.includes('javascript') && - !mimeType.includes('x-empty'); + const isBinaryByMime = !mimeType.startsWith('text/') && + !mimeType.includes('json') && + !mimeType.includes('xml') && + !mimeType.includes('javascript') && + !mimeType.includes('x-empty'); // 6. Read file with appropriate encoding + // Respect user's encoding preference if provided, otherwise use MIME-based detection + const requestedEncoding = options.encoding; let content: string; let actualEncoding: 'utf-8' | 'base64'; + let isBinary: boolean; - if (isBinary) { + // Determine final encoding and binary flag + if (requestedEncoding === 'base64') { + // User explicitly requested base64 - always use base64 regardless of MIME type + actualEncoding = 'base64'; + isBinary = true; // Mark as binary when returning base64 + } else if (requestedEncoding === 'utf-8' || requestedEncoding === 'utf8') { + // User explicitly requested UTF-8 - always use UTF-8 regardless of MIME type + actualEncoding = 'utf-8'; + isBinary = false; // Mark as text when returning UTF-8 + } else { + // No explicit encoding requested - use MIME-based detection (original behavior) + actualEncoding = isBinaryByMime ? 'base64' : 'utf-8'; + isBinary = isBinaryByMime; + } + + if (actualEncoding === 'base64') { // Binary files: read as base64, return as-is (DO NOT decode) const base64Command = `base64 -w 0 < ${escapedPath}`; const base64Result = await this.sessionManager.executeInSession(sessionId, base64Command); @@ -302,12 +320,21 @@ export class FileService implements FileSystemOperations { }; } - // 2. Write file using SessionManager with base64 encoding - // Base64 ensures binary files (images, PDFs, etc.) are written correctly - // and avoids heredoc EOF collision issues + // 2. Write file using SessionManager with proper encoding handling const escapedPath = this.escapePath(path); - const base64Content = Buffer.from(content, 'utf-8').toString('base64'); - const command = `echo '${base64Content}' | base64 -d > ${escapedPath}`; + const encoding = options.encoding || 'utf-8'; + + let command: string; + + if (encoding === 'base64') { + // Content is already base64 encoded, decode it directly to file + command = `echo '${content}' | base64 -d > ${escapedPath}`; + } else { + // Content is text, encode to base64 first to ensure safe shell handling + // This avoids heredoc EOF collision issues and handles special characters + const base64Content = Buffer.from(content, 'utf-8').toString('base64'); + command = `echo '${base64Content}' | base64 -d > ${escapedPath}`; + } const execResult = await this.sessionManager.executeInSession(sessionId, command); @@ -1240,4 +1267,4 @@ export class FileService implements FileSystemOperations { } }); } -} \ No newline at end of file +} From 33ebad17b046a153f6445b922d1651123a71e85a Mon Sep 17 00:00:00 2001 From: luxuncang Date: Fri, 31 Oct 2025 09:58:54 +0800 Subject: [PATCH 2/6] fix: validate base64 content and respect encoding parameter in file operations - Add base64 content validation in writeFile to prevent command injection - Validate content contains only valid base64 characters (A-Z, a-z, 0-9, +, /, =) - Return VALIDATION_FAILED error for invalid content - Change from 'echo' to 'printf' for safer shell handling - Support user-specified encoding in readFile - Respect encoding parameter (base64/utf-8/utf8) over MIME detection - Allow forcing base64 for text files or utf-8 for binary files - Maintain backward compatibility with auto-detection when no encoding specified - Add comprehensive test coverage - 4 tests for encoding parameter support - 5 tests for base64 validation and security - All tests passing (35/35 in file-service.test.ts) Addresses reviewer feedback from PR #167: - Sanitize base64 content to prevent command injection attacks - Remove unused variable declarations --- .../src/services/file-service.ts | 27 +- .../tests/services/file-service.test.ts | 279 +++++++++++++++++- 2 files changed, 300 insertions(+), 6 deletions(-) diff --git a/packages/sandbox-container/src/services/file-service.ts b/packages/sandbox-container/src/services/file-service.ts index af4eb7d7..4c702f69 100644 --- a/packages/sandbox-container/src/services/file-service.ts +++ b/packages/sandbox-container/src/services/file-service.ts @@ -220,7 +220,6 @@ export class FileService implements FileSystemOperations { // 6. Read file with appropriate encoding // Respect user's encoding preference if provided, otherwise use MIME-based detection const requestedEncoding = options.encoding; - let content: string; let actualEncoding: 'utf-8' | 'base64'; let isBinary: boolean; @@ -239,6 +238,8 @@ export class FileService implements FileSystemOperations { isBinary = isBinaryByMime; } + let content: string; + if (actualEncoding === 'base64') { // Binary files: read as base64, return as-is (DO NOT decode) const base64Command = `base64 -w 0 < ${escapedPath}`; @@ -390,13 +391,31 @@ export class FileService implements FileSystemOperations { let command: string; if (encoding === 'base64') { - // Content is already base64 encoded, decode it directly to file - command = `echo '${content}' | base64 -d > ${escapedPath}`; + // Content is already base64 encoded, validate and decode it directly to file + // Validate that content only contains valid base64 characters to prevent command injection + if (!/^[A-Za-z0-9+/=]*$/.test(content)) { + return { + success: false, + error: { + message: `Invalid base64 content for '${path}': contains non-base64 characters`, + code: ErrorCode.VALIDATION_FAILED, + details: { + validationErrors: [{ + field: 'content', + message: 'Content must contain only valid base64 characters (A-Z, a-z, 0-9, +, /, =)', + code: 'INVALID_BASE64' + }] + } satisfies ValidationFailedContext + } + }; + } + // Use printf to avoid single quote issues in echo + command = `printf '%s' '${content}' | base64 -d > ${escapedPath}`; } else { // Content is text, encode to base64 first to ensure safe shell handling // This avoids heredoc EOF collision issues and handles special characters const base64Content = Buffer.from(content, 'utf-8').toString('base64'); - command = `echo '${base64Content}' | base64 -d > ${escapedPath}`; + command = `printf '%s' '${base64Content}' | base64 -d > ${escapedPath}`; } const execResult = await this.sessionManager.executeInSession( diff --git a/packages/sandbox-container/tests/services/file-service.test.ts b/packages/sandbox-container/tests/services/file-service.test.ts index 29c39463..c4d309bf 100644 --- a/packages/sandbox-container/tests/services/file-service.test.ts +++ b/packages/sandbox-container/tests/services/file-service.test.ts @@ -350,6 +350,184 @@ describe('FileService', () => { expect(result.error.code).toBe('FILESYSTEM_ERROR'); } }); + + it('should force base64 encoding when explicitly requested', async () => { + const testPath = '/tmp/text.txt'; + const testContent = 'Hello World'; + const base64Content = Buffer.from(testContent).toString('base64'); + + // Mock exists check + mocked(mockSessionManager.executeInSession).mockResolvedValueOnce({ + success: true, + data: { exitCode: 0, stdout: '', stderr: '' } + } as ServiceResult); + + // Mock stat command (file size) + mocked(mockSessionManager.executeInSession).mockResolvedValueOnce({ + success: true, + data: { exitCode: 0, stdout: '11', stderr: '' } + } as ServiceResult); + + // Mock MIME type detection - text file + mocked(mockSessionManager.executeInSession).mockResolvedValueOnce({ + success: true, + data: { exitCode: 0, stdout: 'text/plain', stderr: '' } + } as ServiceResult); + + // Mock base64 command (even though MIME type is text) + mocked(mockSessionManager.executeInSession).mockResolvedValueOnce({ + success: true, + data: { exitCode: 0, stdout: base64Content, stderr: '' } + } as ServiceResult); + + const result = await fileService.read( + testPath, + { encoding: 'base64' }, + 'session-123' + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(base64Content); + expect(result.metadata?.encoding).toBe('base64'); + expect(result.metadata?.isBinary).toBe(true); // Marked as binary when base64 requested + expect(result.metadata?.mimeType).toBe('text/plain'); + } + + // Verify base64 command was called instead of cat + expect(mockSessionManager.executeInSession).toHaveBeenCalledWith( + 'session-123', + "base64 -w 0 < '/tmp/text.txt'" + ); + }); + + it('should force utf-8 encoding when explicitly requested', async () => { + const testPath = '/tmp/data.bin'; + const testContent = 'Some text content'; + + // Mock exists check + mocked(mockSessionManager.executeInSession).mockResolvedValueOnce({ + success: true, + data: { exitCode: 0, stdout: '', stderr: '' } + } as ServiceResult); + + // Mock stat command (file size) + mocked(mockSessionManager.executeInSession).mockResolvedValueOnce({ + success: true, + data: { exitCode: 0, stdout: '17', stderr: '' } + } as ServiceResult); + + // Mock MIME type detection - binary file + mocked(mockSessionManager.executeInSession).mockResolvedValueOnce({ + success: true, + data: { exitCode: 0, stdout: 'application/octet-stream', stderr: '' } + } as ServiceResult); + + // Mock cat command (even though MIME type is binary) + mocked(mockSessionManager.executeInSession).mockResolvedValueOnce({ + success: true, + data: { exitCode: 0, stdout: testContent, stderr: '' } + } as ServiceResult); + + const result = await fileService.read( + testPath, + { encoding: 'utf-8' }, + 'session-123' + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(testContent); + expect(result.metadata?.encoding).toBe('utf-8'); + expect(result.metadata?.isBinary).toBe(false); // Marked as text when utf-8 requested + expect(result.metadata?.mimeType).toBe('application/octet-stream'); + } + + // Verify cat command was called instead of base64 + expect(mockSessionManager.executeInSession).toHaveBeenCalledWith( + 'session-123', + "cat '/tmp/data.bin'" + ); + }); + + it('should support utf8 as alias for utf-8 encoding', async () => { + const testPath = '/tmp/test.txt'; + const testContent = 'Test content'; + + // Mock exists check + mocked(mockSessionManager.executeInSession).mockResolvedValueOnce({ + success: true, + data: { exitCode: 0, stdout: '', stderr: '' } + } as ServiceResult); + + // Mock stat command + mocked(mockSessionManager.executeInSession).mockResolvedValueOnce({ + success: true, + data: { exitCode: 0, stdout: '12', stderr: '' } + } as ServiceResult); + + // Mock MIME type detection + mocked(mockSessionManager.executeInSession).mockResolvedValueOnce({ + success: true, + data: { exitCode: 0, stdout: 'application/octet-stream', stderr: '' } + } as ServiceResult); + + // Mock cat command + mocked(mockSessionManager.executeInSession).mockResolvedValueOnce({ + success: true, + data: { exitCode: 0, stdout: testContent, stderr: '' } + } as ServiceResult); + + const result = await fileService.read( + testPath, + { encoding: 'utf8' }, // Using 'utf8' instead of 'utf-8' + 'session-123' + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.metadata?.encoding).toBe('utf-8'); + expect(result.metadata?.isBinary).toBe(false); + } + }); + + it('should use MIME-based detection when no encoding specified', async () => { + const testPath = '/tmp/auto.json'; + const testContent = '{"key": "value"}'; + + // Mock exists check + mocked(mockSessionManager.executeInSession).mockResolvedValueOnce({ + success: true, + data: { exitCode: 0, stdout: '', stderr: '' } + } as ServiceResult); + + // Mock stat command + mocked(mockSessionManager.executeInSession).mockResolvedValueOnce({ + success: true, + data: { exitCode: 0, stdout: '16', stderr: '' } + } as ServiceResult); + + // Mock MIME type detection - JSON + mocked(mockSessionManager.executeInSession).mockResolvedValueOnce({ + success: true, + data: { exitCode: 0, stdout: 'application/json', stderr: '' } + } as ServiceResult); + + // Mock cat command (JSON is text-like) + mocked(mockSessionManager.executeInSession).mockResolvedValueOnce({ + success: true, + data: { exitCode: 0, stdout: testContent, stderr: '' } + } as ServiceResult); + + const result = await fileService.read(testPath, {}, 'session-123'); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(testContent); + expect(result.metadata?.encoding).toBe('utf-8'); + expect(result.metadata?.isBinary).toBe(false); + } + }); }); describe('write', () => { @@ -378,13 +556,110 @@ describe('FileService', () => { expect(result.success).toBe(true); - // Verify SessionManager was called with base64 encoded content (cwd is undefined, so only 2 params) + // Verify SessionManager was called with base64 encoded content expect(mockSessionManager.executeInSession).toHaveBeenCalledWith( 'session-123', - `echo '${base64Content}' | base64 -d > '/tmp/test.txt'` + `printf '%s' '${base64Content}' | base64 -d > '/tmp/test.txt'` ); }); + it('should write binary file with base64 encoding option', async () => { + const testPath = '/tmp/image.png'; + const binaryData = Buffer.from([0x89, 0x50, 0x4e, 0x47]); // PNG header + const base64Content = binaryData.toString('base64'); + + mocked(mockSessionManager.executeInSession).mockResolvedValue({ + success: true, + data: { + exitCode: 0, + stdout: '', + stderr: '' + } + } as ServiceResult); + + const result = await fileService.write( + testPath, + base64Content, + { encoding: 'base64' }, + 'session-123' + ); + + expect(result.success).toBe(true); + + // Verify that content is passed directly without re-encoding + expect(mockSessionManager.executeInSession).toHaveBeenCalledWith( + 'session-123', + `printf '%s' '${base64Content}' | base64 -d > '/tmp/image.png'` + ); + }); + + it('should reject base64 content with invalid characters', async () => { + const testPath = '/tmp/test.txt'; + // Malicious content with command injection attempt + const maliciousContent = "abc'; rm -rf / #"; + + const result = await fileService.write( + testPath, + maliciousContent, + { encoding: 'base64' }, + 'session-123' + ); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.code).toBe('VALIDATION_FAILED'); + expect(result.error.message).toContain('Invalid base64 content'); + expect(result.error.message).toContain('non-base64 characters'); + } + + // Verify SessionManager was never called + expect(mockSessionManager.executeInSession).not.toHaveBeenCalled(); + }); + + it('should reject base64 content with shell metacharacters', async () => { + const testPath = '/tmp/test.txt'; + const maliciousContent = 'valid$(whoami)base64'; + + const result = await fileService.write( + testPath, + maliciousContent, + { encoding: 'base64' }, + 'session-123' + ); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.code).toBe('VALIDATION_FAILED'); + } + + // Verify SessionManager was never called + expect(mockSessionManager.executeInSession).not.toHaveBeenCalled(); + }); + + it('should accept valid base64 content with padding', async () => { + const testPath = '/tmp/test.txt'; + const validBase64 = 'SGVsbG8gV29ybGQ='; // "Hello World" with padding + + mocked(mockSessionManager.executeInSession).mockResolvedValue({ + success: true, + data: { + exitCode: 0, + stdout: '', + stderr: '' + } + } as ServiceResult); + + const result = await fileService.write( + testPath, + validBase64, + { encoding: 'base64' }, + 'session-123' + ); + + expect(result.success).toBe(true); + expect(mockSessionManager.executeInSession).toHaveBeenCalled(); + }); + it('should handle write errors', async () => { mocked(mockSessionManager.executeInSession).mockResolvedValue({ success: true, From 6e98909ff0a1de1e7376c7bac3d3e86747f212d4 Mon Sep 17 00:00:00 2001 From: Naresh Date: Mon, 10 Nov 2025 15:38:15 +0000 Subject: [PATCH 3/6] Update lockfile --- package-lock.json | 25 +++++-------------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/package-lock.json b/package-lock.json index 870d390b..db3464b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -181,7 +181,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1130,8 +1129,7 @@ "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20251014.0.tgz", "integrity": "sha512-tEW98J/kOa0TdylIUOrLKRdwkUw0rvvYVlo+Ce0mqRH3c8kSoxLzUH9gfCvwLe0M89z1RkzFovSKAW2Nwtyn3w==", "dev": true, - "license": "MIT OR Apache-2.0", - "peer": true + "license": "MIT OR Apache-2.0" }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", @@ -2283,7 +2281,6 @@ "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -3316,7 +3313,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -3454,7 +3450,6 @@ "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", @@ -3470,7 +3465,6 @@ "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", @@ -3499,7 +3493,6 @@ "integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "3.2.4", "fflate": "^0.8.2", @@ -3721,7 +3714,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -5137,6 +5129,7 @@ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", + "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -6214,6 +6207,7 @@ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -6635,7 +6629,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6657,7 +6650,8 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-katex": { "version": "3.1.0", @@ -6842,7 +6836,6 @@ "integrity": "sha512-iMmuD72XXLf26Tqrv1cryNYLX6NNPLhZ3AmNkSf8+xda0H+yijjGJ+wVT9UdBUHOpKzq9RjKtQKRCWoEKQQBZQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@oxc-project/types": "=0.95.0", "@rolldown/pluginutils": "1.0.0-beta.45" @@ -7503,7 +7496,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7672,7 +7664,6 @@ "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -7869,7 +7860,6 @@ "integrity": "sha512-Wj7/AMtE9MRnAXa6Su3Lk0LNCfqDYgfwVjwRFVum9U7wsto1imuHqk4kTm7Jni+5A0Hn7dttL6O/zjvUvoo+8A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "defu": "^6.1.4", "exsolve": "^1.0.7", @@ -8088,7 +8078,6 @@ "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -8205,7 +8194,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8219,7 +8207,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -8375,7 +8362,6 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "bin": { "workerd": "bin/workerd" }, @@ -8904,7 +8890,6 @@ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "devOptional": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, From 83e35600b00f78ef00767e00e345236c04b9c28e Mon Sep 17 00:00:00 2001 From: Naresh Date: Mon, 10 Nov 2025 17:16:45 +0000 Subject: [PATCH 4/6] Clean up encoding logic and improve comments Remove redundant intermediate variables and assignments in read/write. Clarify shell command comments to accurately explain printf usage and base64 encoding rationale. Use printf consistently for both paths. --- .../src/services/file-service.ts | 55 ++-- .../tests/services/file-service.test.ts | 238 +++++++----------- 2 files changed, 118 insertions(+), 175 deletions(-) diff --git a/packages/sandbox-container/src/services/file-service.ts b/packages/sandbox-container/src/services/file-service.ts index 4c702f69..ba5942bb 100644 --- a/packages/sandbox-container/src/services/file-service.ts +++ b/packages/sandbox-container/src/services/file-service.ts @@ -211,35 +211,26 @@ export class FileService implements FileSystemOperations { // 5. Determine if file is binary based on MIME type // Text MIME types: text/*, application/json, application/xml, application/javascript, etc. - const isBinaryByMime = !mimeType.startsWith('text/') && - !mimeType.includes('json') && - !mimeType.includes('xml') && - !mimeType.includes('javascript') && - !mimeType.includes('x-empty'); + const isBinary = + !mimeType.startsWith('text/') && + !mimeType.includes('json') && + !mimeType.includes('xml') && + !mimeType.includes('javascript') && + !mimeType.includes('x-empty'); // 6. Read file with appropriate encoding // Respect user's encoding preference if provided, otherwise use MIME-based detection - const requestedEncoding = options.encoding; let actualEncoding: 'utf-8' | 'base64'; - let isBinary: boolean; - - // Determine final encoding and binary flag - if (requestedEncoding === 'base64') { - // User explicitly requested base64 - always use base64 regardless of MIME type + if (options.encoding === 'base64') { actualEncoding = 'base64'; - isBinary = true; // Mark as binary when returning base64 - } else if (requestedEncoding === 'utf-8' || requestedEncoding === 'utf8') { - // User explicitly requested UTF-8 - always use UTF-8 regardless of MIME type + } else if (options.encoding === 'utf-8' || options.encoding === 'utf8') { actualEncoding = 'utf-8'; - isBinary = false; // Mark as text when returning UTF-8 } else { // No explicit encoding requested - use MIME-based detection (original behavior) - actualEncoding = isBinaryByMime ? 'base64' : 'utf-8'; - isBinary = isBinaryByMime; + actualEncoding = isBinary ? 'base64' : 'utf-8'; } let content: string; - if (actualEncoding === 'base64') { // Binary files: read as base64, return as-is (DO NOT decode) const base64Command = `base64 -w 0 < ${escapedPath}`; @@ -279,7 +270,6 @@ export class FileService implements FileSystemOperations { } content = base64Result.data.stdout.trim(); - actualEncoding = 'base64'; } else { // Text files: read normally const catCommand = `cat ${escapedPath}`; @@ -319,7 +309,6 @@ export class FileService implements FileSystemOperations { } content = catResult.data.stdout; - actualEncoding = 'utf-8'; } return { @@ -327,7 +316,7 @@ export class FileService implements FileSystemOperations { data: content, metadata: { encoding: actualEncoding, - isBinary, + isBinary: actualEncoding === 'base64', mimeType, size: fileSize } @@ -387,9 +376,9 @@ export class FileService implements FileSystemOperations { // 2. Write file using SessionManager with proper encoding handling const escapedPath = this.escapePath(path); const encoding = options.encoding || 'utf-8'; - + let command: string; - + if (encoding === 'base64') { // Content is already base64 encoded, validate and decode it directly to file // Validate that content only contains valid base64 characters to prevent command injection @@ -397,23 +386,25 @@ export class FileService implements FileSystemOperations { return { success: false, error: { - message: `Invalid base64 content for '${path}': contains non-base64 characters`, + message: `Invalid base64 content for '${path}': must contain only A-Z, a-z, 0-9, +, /, =`, code: ErrorCode.VALIDATION_FAILED, details: { - validationErrors: [{ - field: 'content', - message: 'Content must contain only valid base64 characters (A-Z, a-z, 0-9, +, /, =)', - code: 'INVALID_BASE64' - }] + validationErrors: [ + { + field: 'content', + message: 'Invalid base64 characters', + code: 'INVALID_BASE64' + } + ] } satisfies ValidationFailedContext } }; } - // Use printf to avoid single quote issues in echo + // Use printf to output base64 literally without trailing newline command = `printf '%s' '${content}' | base64 -d > ${escapedPath}`; } else { - // Content is text, encode to base64 first to ensure safe shell handling - // This avoids heredoc EOF collision issues and handles special characters + // Encode text to base64 to safely handle shell metacharacters (quotes, backticks, $, etc.) + // and special characters (newlines, control chars, null bytes) in user content const base64Content = Buffer.from(content, 'utf-8').toString('base64'); command = `printf '%s' '${base64Content}' | base64 -d > ${escapedPath}`; } diff --git a/packages/sandbox-container/tests/services/file-service.test.ts b/packages/sandbox-container/tests/services/file-service.test.ts index c4d309bf..1d6c9cb2 100644 --- a/packages/sandbox-container/tests/services/file-service.test.ts +++ b/packages/sandbox-container/tests/services/file-service.test.ts @@ -56,6 +56,37 @@ describe('FileService', () => { }); describe('read', () => { + // Helper to setup common mocks for read operations + function setupReadMocks( + fileSize: number, + mimeType: string, + commandOutput: string + ) { + // Mock exists check + mocked(mockSessionManager.executeInSession).mockResolvedValueOnce({ + success: true, + data: { exitCode: 0, stdout: '', stderr: '' } + } as ServiceResult); + + // Mock stat command + mocked(mockSessionManager.executeInSession).mockResolvedValueOnce({ + success: true, + data: { exitCode: 0, stdout: fileSize.toString(), stderr: '' } + } as ServiceResult); + + // Mock MIME type detection + mocked(mockSessionManager.executeInSession).mockResolvedValueOnce({ + success: true, + data: { exitCode: 0, stdout: mimeType, stderr: '' } + } as ServiceResult); + + // Mock file read command (base64 or cat) + mocked(mockSessionManager.executeInSession).mockResolvedValueOnce({ + success: true, + data: { exitCode: 0, stdout: commandOutput, stderr: '' } + } as ServiceResult); + } + it('should read text file with MIME type detection', async () => { const testPath = '/tmp/test.txt'; const testContent = 'Hello, World!'; @@ -356,29 +387,7 @@ describe('FileService', () => { const testContent = 'Hello World'; const base64Content = Buffer.from(testContent).toString('base64'); - // Mock exists check - mocked(mockSessionManager.executeInSession).mockResolvedValueOnce({ - success: true, - data: { exitCode: 0, stdout: '', stderr: '' } - } as ServiceResult); - - // Mock stat command (file size) - mocked(mockSessionManager.executeInSession).mockResolvedValueOnce({ - success: true, - data: { exitCode: 0, stdout: '11', stderr: '' } - } as ServiceResult); - - // Mock MIME type detection - text file - mocked(mockSessionManager.executeInSession).mockResolvedValueOnce({ - success: true, - data: { exitCode: 0, stdout: 'text/plain', stderr: '' } - } as ServiceResult); - - // Mock base64 command (even though MIME type is text) - mocked(mockSessionManager.executeInSession).mockResolvedValueOnce({ - success: true, - data: { exitCode: 0, stdout: base64Content, stderr: '' } - } as ServiceResult); + setupReadMocks(11, 'text/plain', base64Content); const result = await fileService.read( testPath, @@ -390,11 +399,10 @@ describe('FileService', () => { if (result.success) { expect(result.data).toBe(base64Content); expect(result.metadata?.encoding).toBe('base64'); - expect(result.metadata?.isBinary).toBe(true); // Marked as binary when base64 requested + expect(result.metadata?.isBinary).toBe(true); expect(result.metadata?.mimeType).toBe('text/plain'); } - // Verify base64 command was called instead of cat expect(mockSessionManager.executeInSession).toHaveBeenCalledWith( 'session-123', "base64 -w 0 < '/tmp/text.txt'" @@ -405,29 +413,7 @@ describe('FileService', () => { const testPath = '/tmp/data.bin'; const testContent = 'Some text content'; - // Mock exists check - mocked(mockSessionManager.executeInSession).mockResolvedValueOnce({ - success: true, - data: { exitCode: 0, stdout: '', stderr: '' } - } as ServiceResult); - - // Mock stat command (file size) - mocked(mockSessionManager.executeInSession).mockResolvedValueOnce({ - success: true, - data: { exitCode: 0, stdout: '17', stderr: '' } - } as ServiceResult); - - // Mock MIME type detection - binary file - mocked(mockSessionManager.executeInSession).mockResolvedValueOnce({ - success: true, - data: { exitCode: 0, stdout: 'application/octet-stream', stderr: '' } - } as ServiceResult); - - // Mock cat command (even though MIME type is binary) - mocked(mockSessionManager.executeInSession).mockResolvedValueOnce({ - success: true, - data: { exitCode: 0, stdout: testContent, stderr: '' } - } as ServiceResult); + setupReadMocks(17, 'application/octet-stream', testContent); const result = await fileService.read( testPath, @@ -439,85 +425,34 @@ describe('FileService', () => { if (result.success) { expect(result.data).toBe(testContent); expect(result.metadata?.encoding).toBe('utf-8'); - expect(result.metadata?.isBinary).toBe(false); // Marked as text when utf-8 requested + expect(result.metadata?.isBinary).toBe(false); expect(result.metadata?.mimeType).toBe('application/octet-stream'); } - // Verify cat command was called instead of base64 expect(mockSessionManager.executeInSession).toHaveBeenCalledWith( 'session-123', "cat '/tmp/data.bin'" ); - }); - it('should support utf8 as alias for utf-8 encoding', async () => { - const testPath = '/tmp/test.txt'; - const testContent = 'Test content'; - - // Mock exists check - mocked(mockSessionManager.executeInSession).mockResolvedValueOnce({ - success: true, - data: { exitCode: 0, stdout: '', stderr: '' } - } as ServiceResult); - - // Mock stat command - mocked(mockSessionManager.executeInSession).mockResolvedValueOnce({ - success: true, - data: { exitCode: 0, stdout: '12', stderr: '' } - } as ServiceResult); - - // Mock MIME type detection - mocked(mockSessionManager.executeInSession).mockResolvedValueOnce({ - success: true, - data: { exitCode: 0, stdout: 'application/octet-stream', stderr: '' } - } as ServiceResult); - - // Mock cat command - mocked(mockSessionManager.executeInSession).mockResolvedValueOnce({ - success: true, - data: { exitCode: 0, stdout: testContent, stderr: '' } - } as ServiceResult); + // Also test 'utf8' alias works the same way + vi.clearAllMocks(); + setupReadMocks(17, 'application/octet-stream', testContent); - const result = await fileService.read( + const aliasResult = await fileService.read( testPath, - { encoding: 'utf8' }, // Using 'utf8' instead of 'utf-8' + { encoding: 'utf8' }, 'session-123' ); - expect(result.success).toBe(true); - if (result.success) { - expect(result.metadata?.encoding).toBe('utf-8'); - expect(result.metadata?.isBinary).toBe(false); - } + expect(aliasResult.success).toBe(true); + expect(aliasResult.metadata?.encoding).toBe('utf-8'); }); it('should use MIME-based detection when no encoding specified', async () => { const testPath = '/tmp/auto.json'; const testContent = '{"key": "value"}'; - // Mock exists check - mocked(mockSessionManager.executeInSession).mockResolvedValueOnce({ - success: true, - data: { exitCode: 0, stdout: '', stderr: '' } - } as ServiceResult); - - // Mock stat command - mocked(mockSessionManager.executeInSession).mockResolvedValueOnce({ - success: true, - data: { exitCode: 0, stdout: '16', stderr: '' } - } as ServiceResult); - - // Mock MIME type detection - JSON - mocked(mockSessionManager.executeInSession).mockResolvedValueOnce({ - success: true, - data: { exitCode: 0, stdout: 'application/json', stderr: '' } - } as ServiceResult); - - // Mock cat command (JSON is text-like) - mocked(mockSessionManager.executeInSession).mockResolvedValueOnce({ - success: true, - data: { exitCode: 0, stdout: testContent, stderr: '' } - } as ServiceResult); + setupReadMocks(16, 'application/json', testContent); const result = await fileService.read(testPath, {}, 'session-123'); @@ -563,10 +498,12 @@ describe('FileService', () => { ); }); - it('should write binary file with base64 encoding option', async () => { - const testPath = '/tmp/image.png'; - const binaryData = Buffer.from([0x89, 0x50, 0x4e, 0x47]); // PNG header - const base64Content = binaryData.toString('base64'); + it('should support utf8 as alias for utf-8 encoding in write', async () => { + const testPath = '/tmp/test.txt'; + const testContent = 'Test content'; + const base64Content = Buffer.from(testContent, 'utf-8').toString( + 'base64' + ); mocked(mockSessionManager.executeInSession).mockResolvedValue({ success: true, @@ -579,61 +516,76 @@ describe('FileService', () => { const result = await fileService.write( testPath, - base64Content, - { encoding: 'base64' }, + testContent, + { encoding: 'utf8' }, 'session-123' ); expect(result.success).toBe(true); - // Verify that content is passed directly without re-encoding + // Verify text encoding path is used (printf + base64) expect(mockSessionManager.executeInSession).toHaveBeenCalledWith( 'session-123', - `printf '%s' '${base64Content}' | base64 -d > '/tmp/image.png'` + `printf '%s' '${base64Content}' | base64 -d > '/tmp/test.txt'` ); }); - it('should reject base64 content with invalid characters', async () => { - const testPath = '/tmp/test.txt'; - // Malicious content with command injection attempt - const maliciousContent = "abc'; rm -rf / #"; + it('should write binary file with base64 encoding option', async () => { + const testPath = '/tmp/image.png'; + const binaryData = Buffer.from([0x89, 0x50, 0x4e, 0x47]); // PNG header + const base64Content = binaryData.toString('base64'); + + mocked(mockSessionManager.executeInSession).mockResolvedValue({ + success: true, + data: { + exitCode: 0, + stdout: '', + stderr: '' + } + } as ServiceResult); const result = await fileService.write( testPath, - maliciousContent, + base64Content, { encoding: 'base64' }, 'session-123' ); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error.code).toBe('VALIDATION_FAILED'); - expect(result.error.message).toContain('Invalid base64 content'); - expect(result.error.message).toContain('non-base64 characters'); - } + expect(result.success).toBe(true); - // Verify SessionManager was never called - expect(mockSessionManager.executeInSession).not.toHaveBeenCalled(); + // Verify that content is passed directly without re-encoding + expect(mockSessionManager.executeInSession).toHaveBeenCalledWith( + 'session-123', + `printf '%s' '${base64Content}' | base64 -d > '/tmp/image.png'` + ); }); - it('should reject base64 content with shell metacharacters', async () => { + it('should reject base64 content with invalid characters', async () => { const testPath = '/tmp/test.txt'; - const maliciousContent = 'valid$(whoami)base64'; - const result = await fileService.write( - testPath, - maliciousContent, - { encoding: 'base64' }, - 'session-123' - ); + const maliciousInputs = [ + "abc'; rm -rf / #", // Shell command injection + 'valid$(whoami)base64', // Command substitution + 'test\nmalicious', // Newline injection + 'test`whoami`test', // Backtick injection + 'test|whoami', // Pipe injection + 'test&whoami&' // Background command injection + ]; + + for (const maliciousContent of maliciousInputs) { + vi.clearAllMocks(); + + const result = await fileService.write( + testPath, + maliciousContent, + { encoding: 'base64' }, + 'session-123' + ); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error.code).toBe('VALIDATION_FAILED'); + expect(result.success).toBe(false); + expect(result.error?.code).toBe('VALIDATION_FAILED'); + expect(mockSessionManager.executeInSession).not.toHaveBeenCalled(); } - - // Verify SessionManager was never called - expect(mockSessionManager.executeInSession).not.toHaveBeenCalled(); }); it('should accept valid base64 content with padding', async () => { From 67953bb9cf81c98f50851c3803474dc5a295f739 Mon Sep 17 00:00:00 2001 From: Naresh Date: Mon, 10 Nov 2025 18:22:41 +0000 Subject: [PATCH 5/6] Document build system reliability in CLAUDE.md --- CLAUDE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 79da99cb..b048d593 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -217,6 +217,8 @@ npm run test:e2e -- -- tests/e2e/git-clone-workflow.test.ts -t 'should handle cl - Sequential execution (`singleFork: true`) to prevent container resource contention - Longer timeouts (2min per test) for container operations +**Build system trust:** The monorepo build system (turbo + npm workspaces) is robust and handles all package dependencies automatically. E2E tests always run against the latest built code - there's no need to manually rebuild or worry about stale builds unless explicitly working on the build setup itself. + **CI behavior:** E2E tests in CI (`pullrequest.yml`): 1. Build Docker image locally (`npm run docker:local`) From b32abbd8e86b8a9ba587aaa656b80d9789ad3011 Mon Sep 17 00:00:00 2001 From: Naresh Date: Mon, 10 Nov 2025 18:24:46 +0000 Subject: [PATCH 6/6] Remove encoding defaults to enable MIME auto-detection Remove 'utf8'/'utf-8' defaults from SDK client and HTTP handler, allowing file service to perform MIME-based detection when encoding is not explicitly specified. This fixes binary file detection. --- packages/sandbox-container/src/handlers/file-handler.ts | 4 ++-- packages/sandbox/src/clients/file-client.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/sandbox-container/src/handlers/file-handler.ts b/packages/sandbox-container/src/handlers/file-handler.ts index 062d0d96..187785fd 100644 --- a/packages/sandbox-container/src/handlers/file-handler.ts +++ b/packages/sandbox-container/src/handlers/file-handler.ts @@ -75,7 +75,7 @@ export class FileHandler extends BaseHandler { const body = await this.parseRequestBody(request); const result = await this.fileService.readFile(body.path, { - encoding: body.encoding || 'utf-8' + encoding: body.encoding }); if (result.success) { @@ -191,7 +191,7 @@ export class FileHandler extends BaseHandler { const body = await this.parseRequestBody(request); const result = await this.fileService.writeFile(body.path, body.content, { - encoding: body.encoding || 'utf-8' + encoding: body.encoding }); if (result.success) { diff --git a/packages/sandbox/src/clients/file-client.ts b/packages/sandbox/src/clients/file-client.ts index ef302770..ab99cfcc 100644 --- a/packages/sandbox/src/clients/file-client.ts +++ b/packages/sandbox/src/clients/file-client.ts @@ -98,7 +98,7 @@ export class FileClient extends BaseHttpClient { path, content, sessionId, - encoding: options?.encoding ?? 'utf8' + encoding: options?.encoding }; const response = await this.post('/api/write', data); @@ -126,7 +126,7 @@ export class FileClient extends BaseHttpClient { const data = { path, sessionId, - encoding: options?.encoding ?? 'utf8' + encoding: options?.encoding }; const response = await this.post('/api/read', data);