From 8a989b42d1e52e3a715918657c720e9c3b26f662 Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 13 Jan 2026 09:23:46 -0700 Subject: [PATCH 1/2] feat(e2b): add NPM token injection for private package access Adds support for authenticating to private NPM registries in E2B sandboxes. Changes: - Add configureNpmAuth method to SandboxManager class - Add --npm-token and --npm-registry CLI flags to sandbox-run command - Support environment variable fallback (PARALLEL_CC_NPM_TOKEN) - Integrate NPM config injection into sandbox execution flow Security features: - Token never logged (security by design) - Tokens with newlines rejected (injection prevention) - Registry URL validated (http/https only) - HTTP registries trigger warning about insecurity Test coverage: - 22 new tests covering happy path, edge cases, and security - All existing 806 tests still passing --- src/cli.ts | 45 +++- src/e2b/sandbox-manager.ts | 92 ++++++++ tests/e2b/npm-config.test.ts | 432 +++++++++++++++++++++++++++++++++++ 3 files changed, 568 insertions(+), 1 deletion(-) create mode 100644 tests/e2b/npm-config.test.ts diff --git a/src/cli.ts b/src/cli.ts index 5a5ec7e..2dc5f4b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1369,6 +1369,10 @@ Git Identity (for commits in sandbox): PARALLEL_CC_GIT_USER Environment variable fallback PARALLEL_CC_GIT_EMAIL Environment variable fallback +NPM Authentication (for private packages): + --npm-token NPM token (or set PARALLEL_CC_NPM_TOKEN env var) + --npm-registry Custom registry (default: registry.npmjs.org) + Examples: # Default: uncommitted changes, review before committing parallel-cc sandbox-run --repo . --prompt "Fix bug" @@ -1380,7 +1384,13 @@ Examples: parallel-cc sandbox-run --repo . --prompt "Fix #42" --branch feature/issue-42 # Override git identity for commits - parallel-cc sandbox-run --repo . --prompt "Fix bug" --git-user "CI Bot" --git-email "ci@example.com"`) + parallel-cc sandbox-run --repo . --prompt "Fix bug" --git-user "CI Bot" --git-email "ci@example.com" + + # Private NPM packages + parallel-cc sandbox-run --repo . --prompt "Install deps" --npm-token "npm_xxx" + + # Custom NPM registry + parallel-cc sandbox-run --repo . --prompt "Task" --npm-token "xxx" --npm-registry "https://npm.company.com"`) .requiredOption('--repo ', 'Repository path') .option('--prompt ', 'Prompt text to execute') .option('--prompt-file ', 'Path to prompt file (e.g., PLAN.md, .apm/Implementation_Plan.md)') @@ -1394,6 +1404,8 @@ Examples: .option('--git-email ', 'Git user email for commits in sandbox (default: auto-detect from local git config)') .option('--ssh-key ', 'Path to SSH private key for private repository access (e.g., ~/.ssh/id_ed25519)') .option('--confirm-ssh-key', 'Skip interactive SSH key security warning (for non-interactive use)') + .option('--npm-token ', 'NPM authentication token for private packages (or set PARALLEL_CC_NPM_TOKEN env var)') + .option('--npm-registry ', 'Custom NPM registry URL (default: https://registry.npmjs.org)', 'https://registry.npmjs.org') .option('--json', 'Output as JSON') .action(async (options) => { const coordinator = new Coordinator(); @@ -1732,6 +1744,37 @@ Examples: } } + // Configure NPM authentication if token provided + // Priority: CLI flag > environment variable + const npmToken = options.npmToken || process.env.PARALLEL_CC_NPM_TOKEN; + if (npmToken) { + if (!options.json) { + console.log(chalk.dim(' Configuring NPM authentication for private packages...')); + } + + const npmConfigResult = await sandboxManager.configureNpmAuth( + sandbox, + npmToken, + options.npmRegistry + ); + + if (npmConfigResult) { + if (!options.json) { + console.log(chalk.green('✓ NPM authentication configured')); + // Don't log the registry URL if it's the default + if (options.npmRegistry !== 'https://registry.npmjs.org') { + console.log(chalk.dim(` Registry: ${options.npmRegistry}`)); + } + } + } else { + // NPM config failure is non-blocking - warn but continue + if (!options.json) { + console.warn(chalk.yellow('⚠ NPM authentication configuration failed')); + console.warn(chalk.dim(' Continuing without private package access')); + } + } + } + // Create E2B session in database const db = coordinator['db']; db.createE2BSession({ diff --git a/src/e2b/sandbox-manager.ts b/src/e2b/sandbox-manager.ts index 8572ded..dc7f20d 100644 --- a/src/e2b/sandbox-manager.ts +++ b/src/e2b/sandbox-manager.ts @@ -542,6 +542,98 @@ export class SandboxManager { } } + /** + * Configure NPM authentication in sandbox for private package access + * + * Creates ~/.npmrc file with authentication token for the specified registry. + * This enables npm/yarn/pnpm to install private packages. + * + * Security: + * - Token is never logged + * - Token is sanitized (newlines removed) + * - Registry URL is validated + * + * @param sandbox - E2B Sandbox instance + * @param npmToken - NPM authentication token + * @param npmRegistry - NPM registry URL (default: https://registry.npmjs.org) + * @returns boolean indicating success + */ + async configureNpmAuth( + sandbox: Sandbox, + npmToken: string, + npmRegistry: string = 'https://registry.npmjs.org' + ): Promise { + try { + // Validate sandbox + if (!sandbox || !sandbox.files) { + this.logger.error('Invalid sandbox: missing files API'); + return false; + } + + // Validate token + if (!npmToken || typeof npmToken !== 'string' || !npmToken.trim()) { + this.logger.error('Invalid NPM token: must be a non-empty string'); + return false; + } + + // Reject tokens containing newlines (injection attack prevention) + // Legitimate NPM tokens never contain newlines + if (/[\r\n]/.test(npmToken)) { + this.logger.error('Invalid NPM token: contains newline characters'); + return false; + } + + const sanitizedToken = npmToken.trim(); + + // Validate registry URL + let parsedUrl: URL; + try { + parsedUrl = new URL(npmRegistry); + } catch { + this.logger.error(`Invalid registry URL format: ${npmRegistry}`); + return false; + } + + // Check protocol + if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') { + this.logger.error(`Invalid registry URL: must use http or https protocol`); + return false; + } + + // Warn about insecure http + if (parsedUrl.protocol === 'http:') { + this.logger.warn('Using HTTP for NPM registry is insecure. Consider using HTTPS.'); + } + + // Build registry hostname (including port if present, and path if present) + let registryHost = parsedUrl.host; // includes port if specified + if (parsedUrl.pathname && parsedUrl.pathname !== '/') { + // Remove trailing slash from pathname for consistent format + const cleanPath = parsedUrl.pathname.replace(/\/$/, ''); + registryHost += cleanPath; + } + + // Build .npmrc content + const npmrcContent = [ + `//registry.npmjs.org/:_authToken=${sanitizedToken}`.replace( + 'registry.npmjs.org', + registryHost + ), + `registry=${npmRegistry.replace(/\/$/, '')}` // Remove trailing slash + ].join('\n') + '\n'; + + // Write .npmrc file + await sandbox.files.write('/root/.npmrc', npmrcContent); + + this.logger.info('NPM authentication configured successfully'); + return true; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + this.logger.error(`Failed to configure NPM config: ${errorMsg}`); + return false; + } + } + /** * Get sandbox cost estimation * diff --git a/tests/e2b/npm-config.test.ts b/tests/e2b/npm-config.test.ts new file mode 100644 index 0000000..56c1d99 --- /dev/null +++ b/tests/e2b/npm-config.test.ts @@ -0,0 +1,432 @@ +/** + * Tests for NPM Configuration in E2B Sandboxes + * + * Tests NPM token injection for private package access: + * - configureNpmAuth method validation + * - .npmrc file creation with correct format + * - Registry URL handling (default and custom) + * - Token security (never logged) + * - Error handling + * + * All E2B operations are mocked - no real sandbox operations occur. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { Sandbox } from 'e2b'; + +// Mock logger +const mockLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn() +}; + +describe('NPM Configuration for E2B Sandboxes', () => { + let mockSandbox: any; + + beforeEach(() => { + vi.clearAllMocks(); + + // Create mock sandbox with E2B-like API + mockSandbox = { + files: { + write: vi.fn().mockResolvedValue(undefined), + read: vi.fn().mockResolvedValue(Buffer.from('')) + }, + commands: { + run: vi.fn().mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }) + } + }; + }); + + describe('configureNpmAuth', () => { + it('should create .npmrc file with token for default registry', async () => { + const { SandboxManager } = await import('../../src/e2b/sandbox-manager.js'); + const manager = new SandboxManager(mockLogger as any); + + const result = await manager.configureNpmAuth( + mockSandbox as Sandbox, + 'npm_test_token_123' + ); + + expect(result).toBe(true); + expect(mockSandbox.files.write).toHaveBeenCalledWith( + '/root/.npmrc', + expect.stringContaining('//registry.npmjs.org/:_authToken=npm_test_token_123') + ); + }); + + it('should include registry line in .npmrc', async () => { + const { SandboxManager } = await import('../../src/e2b/sandbox-manager.js'); + const manager = new SandboxManager(mockLogger as any); + + await manager.configureNpmAuth(mockSandbox as Sandbox, 'npm_token'); + + expect(mockSandbox.files.write).toHaveBeenCalledWith( + '/root/.npmrc', + expect.stringContaining('registry=https://registry.npmjs.org') + ); + }); + + it('should handle custom registry URL', async () => { + const { SandboxManager } = await import('../../src/e2b/sandbox-manager.js'); + const manager = new SandboxManager(mockLogger as any); + + const result = await manager.configureNpmAuth( + mockSandbox as Sandbox, + 'npm_token_456', + 'https://npm.company.com' + ); + + expect(result).toBe(true); + expect(mockSandbox.files.write).toHaveBeenCalledWith( + '/root/.npmrc', + expect.stringContaining('//npm.company.com/:_authToken=npm_token_456') + ); + expect(mockSandbox.files.write).toHaveBeenCalledWith( + '/root/.npmrc', + expect.stringContaining('registry=https://npm.company.com') + ); + }); + + it('should handle registry URL with trailing slash', async () => { + const { SandboxManager } = await import('../../src/e2b/sandbox-manager.js'); + const manager = new SandboxManager(mockLogger as any); + + await manager.configureNpmAuth( + mockSandbox as Sandbox, + 'npm_token', + 'https://npm.company.com/' // Trailing slash + ); + + // Should strip trailing slash for hostname extraction + expect(mockSandbox.files.write).toHaveBeenCalledWith( + '/root/.npmrc', + expect.stringContaining('//npm.company.com/:_authToken=') + ); + }); + + it('should reject empty token', async () => { + const { SandboxManager } = await import('../../src/e2b/sandbox-manager.js'); + const manager = new SandboxManager(mockLogger as any); + + const result = await manager.configureNpmAuth( + mockSandbox as Sandbox, + '' + ); + + expect(result).toBe(false); + expect(mockSandbox.files.write).not.toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringMatching(/invalid.*npm.*token/i) + ); + }); + + it('should reject whitespace-only token', async () => { + const { SandboxManager } = await import('../../src/e2b/sandbox-manager.js'); + const manager = new SandboxManager(mockLogger as any); + + const result = await manager.configureNpmAuth( + mockSandbox as Sandbox, + ' ' + ); + + expect(result).toBe(false); + expect(mockSandbox.files.write).not.toHaveBeenCalled(); + }); + + it('should reject invalid registry URL format', async () => { + const { SandboxManager } = await import('../../src/e2b/sandbox-manager.js'); + const manager = new SandboxManager(mockLogger as any); + + const result = await manager.configureNpmAuth( + mockSandbox as Sandbox, + 'npm_token', + 'not-a-valid-url' + ); + + expect(result).toBe(false); + expect(mockSandbox.files.write).not.toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringMatching(/invalid.*registry.*url/i) + ); + }); + + it('should handle file write errors gracefully', async () => { + mockSandbox.files.write.mockRejectedValueOnce(new Error('Permission denied')); + + const { SandboxManager } = await import('../../src/e2b/sandbox-manager.js'); + const manager = new SandboxManager(mockLogger as any); + + const result = await manager.configureNpmAuth( + mockSandbox as Sandbox, + 'npm_token' + ); + + expect(result).toBe(false); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringMatching(/failed.*npm.*config/i) + ); + }); + + it('should log success without exposing token', async () => { + const { SandboxManager } = await import('../../src/e2b/sandbox-manager.js'); + const manager = new SandboxManager(mockLogger as any); + + await manager.configureNpmAuth( + mockSandbox as Sandbox, + 'npm_super_secret_token' + ); + + // Verify info was logged + expect(mockLogger.info).toHaveBeenCalled(); + + // Verify token is NOT in any log message + const allLogCalls = [ + ...mockLogger.info.mock.calls, + ...mockLogger.debug.mock.calls, + ...mockLogger.warn.mock.calls, + ...mockLogger.error.mock.calls + ]; + + const logMessages = allLogCalls.map(call => call[0]); + logMessages.forEach(msg => { + expect(msg).not.toContain('npm_super_secret_token'); + }); + }); + + it('should handle registry with port number', async () => { + const { SandboxManager } = await import('../../src/e2b/sandbox-manager.js'); + const manager = new SandboxManager(mockLogger as any); + + await manager.configureNpmAuth( + mockSandbox as Sandbox, + 'npm_token', + 'https://npm.company.com:8080' + ); + + expect(mockSandbox.files.write).toHaveBeenCalledWith( + '/root/.npmrc', + expect.stringContaining('//npm.company.com:8080/:_authToken=') + ); + }); + + it('should handle registry URL with path', async () => { + const { SandboxManager } = await import('../../src/e2b/sandbox-manager.js'); + const manager = new SandboxManager(mockLogger as any); + + await manager.configureNpmAuth( + mockSandbox as Sandbox, + 'npm_token', + 'https://artifacts.company.com/npm/registry' + ); + + expect(mockSandbox.files.write).toHaveBeenCalledWith( + '/root/.npmrc', + expect.stringContaining('//artifacts.company.com/npm/registry/:_authToken=') + ); + }); + }); + + describe('Registry URL validation', () => { + it('should accept https URLs', async () => { + const { SandboxManager } = await import('../../src/e2b/sandbox-manager.js'); + const manager = new SandboxManager(mockLogger as any); + + const result = await manager.configureNpmAuth( + mockSandbox as Sandbox, + 'token', + 'https://registry.example.com' + ); + + expect(result).toBe(true); + }); + + it('should accept http URLs (for private registries)', async () => { + const { SandboxManager } = await import('../../src/e2b/sandbox-manager.js'); + const manager = new SandboxManager(mockLogger as any); + + const result = await manager.configureNpmAuth( + mockSandbox as Sandbox, + 'token', + 'http://internal.registry.local' + ); + + expect(result).toBe(true); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringMatching(/http.*insecure|insecure.*http/i) + ); + }); + + it('should reject non-http(s) URLs', async () => { + const { SandboxManager } = await import('../../src/e2b/sandbox-manager.js'); + const manager = new SandboxManager(mockLogger as any); + + const result = await manager.configureNpmAuth( + mockSandbox as Sandbox, + 'token', + 'ftp://invalid.registry.com' + ); + + expect(result).toBe(false); + }); + }); + + describe('.npmrc content format', () => { + it('should generate correct .npmrc for default npm registry', async () => { + const { SandboxManager } = await import('../../src/e2b/sandbox-manager.js'); + const manager = new SandboxManager(mockLogger as any); + + await manager.configureNpmAuth(mockSandbox as Sandbox, 'my_token_123'); + + const expectedContent = [ + '//registry.npmjs.org/:_authToken=my_token_123', + 'registry=https://registry.npmjs.org' + ].join('\n'); + + expect(mockSandbox.files.write).toHaveBeenCalledWith( + '/root/.npmrc', + expect.stringContaining('//registry.npmjs.org/:_authToken=my_token_123') + ); + }); + + it('should handle tokens with special characters safely', async () => { + const { SandboxManager } = await import('../../src/e2b/sandbox-manager.js'); + const manager = new SandboxManager(mockLogger as any); + + // NPM tokens can contain alphanumeric and some special chars + const specialToken = 'npm_abc123-XYZ_456'; + + const result = await manager.configureNpmAuth( + mockSandbox as Sandbox, + specialToken + ); + + expect(result).toBe(true); + expect(mockSandbox.files.write).toHaveBeenCalledWith( + '/root/.npmrc', + expect.stringContaining(`_authToken=${specialToken}`) + ); + }); + }); + + describe('Integration with sandbox lifecycle', () => { + it('should configure npm auth before npm operations', async () => { + const { SandboxManager } = await import('../../src/e2b/sandbox-manager.js'); + const manager = new SandboxManager(mockLogger as any); + + // Configure npm auth + const configResult = await manager.configureNpmAuth( + mockSandbox as Sandbox, + 'npm_token' + ); + + expect(configResult).toBe(true); + + // Simulate npm install + mockSandbox.commands.run.mockResolvedValueOnce({ + stdout: 'added 100 packages', + stderr: '', + exitCode: 0 + }); + + const installResult = await mockSandbox.commands.run('npm install'); + expect(installResult.exitCode).toBe(0); + }); + + it('should not block sandbox execution on npm config failure', async () => { + mockSandbox.files.write.mockRejectedValueOnce(new Error('Write failed')); + + const { SandboxManager } = await import('../../src/e2b/sandbox-manager.js'); + const manager = new SandboxManager(mockLogger as any); + + // NPM config fails + const result = await manager.configureNpmAuth( + mockSandbox as Sandbox, + 'npm_token' + ); + + expect(result).toBe(false); + + // But sandbox should still be usable + mockSandbox.commands.run.mockResolvedValueOnce({ + stdout: 'ok', + stderr: '', + exitCode: 0 + }); + + const echoResult = await mockSandbox.commands.run('echo "sandbox works"'); + expect(echoResult.exitCode).toBe(0); + }); + }); + + describe('Edge cases', () => { + it('should handle null sandbox gracefully', async () => { + const { SandboxManager } = await import('../../src/e2b/sandbox-manager.js'); + const manager = new SandboxManager(mockLogger as any); + + const result = await manager.configureNpmAuth( + null as any, + 'npm_token' + ); + + expect(result).toBe(false); + }); + + it('should handle sandbox without files API', async () => { + const { SandboxManager } = await import('../../src/e2b/sandbox-manager.js'); + const manager = new SandboxManager(mockLogger as any); + + const brokenSandbox = { commands: { run: vi.fn() } }; + + const result = await manager.configureNpmAuth( + brokenSandbox as any, + 'npm_token' + ); + + expect(result).toBe(false); + }); + + it('should handle very long tokens', async () => { + const { SandboxManager } = await import('../../src/e2b/sandbox-manager.js'); + const manager = new SandboxManager(mockLogger as any); + + const longToken = 'npm_' + 'a'.repeat(1000); + + const result = await manager.configureNpmAuth( + mockSandbox as Sandbox, + longToken + ); + + expect(result).toBe(true); + expect(mockSandbox.files.write).toHaveBeenCalledWith( + '/root/.npmrc', + expect.stringContaining(longToken) + ); + }); + + it('should handle token with newlines (sanitization)', async () => { + const { SandboxManager } = await import('../../src/e2b/sandbox-manager.js'); + const manager = new SandboxManager(mockLogger as any); + + // Token with embedded newline (malicious attempt) + const maliciousToken = 'npm_token\n//evil.com/:_authToken=stolen'; + + const result = await manager.configureNpmAuth( + mockSandbox as Sandbox, + maliciousToken + ); + + // Should either reject or sanitize + if (result) { + // If accepted, newlines must be removed + const writeCall = mockSandbox.files.write.mock.calls[0]; + expect(writeCall[1]).not.toContain('evil.com'); + } else { + // Or reject entirely + expect(result).toBe(false); + } + }); + }); +}); From 4f341420abb4ff6b8d0636f03c232a70fca4b5a4 Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 13 Jan 2026 09:33:17 -0700 Subject: [PATCH 2/2] fix(e2b): address PR review feedback for NPM token injection Changes based on macroscopeapp[bot] review: 1. Registry URL normalization (PR comment #2687153769) - Strip query/fragment from registry URL - Use same normalized value for both auth scope and registry= line - Prevents npm from failing to match token due to URL mismatch 2. String.replace $ character handling (PR comment #2687153782) - Use function replacement to avoid $ interpretation - Ensures registry paths with $ are preserved literally 3. npmConfigResult check (PR comment #2687153777) - NOT changed: configureNpmAuth returns boolean, not object - Truthiness check is correct for boolean return type Added 2 new tests: - Registry hostname with $ character - Query/fragment stripping from registry URL Test count: 24 NPM config tests, 808 total tests passing --- src/e2b/sandbox-manager.ts | 19 ++++++++------- tests/e2b/npm-config.test.ts | 47 ++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/src/e2b/sandbox-manager.ts b/src/e2b/sandbox-manager.ts index dc7f20d..739451d 100644 --- a/src/e2b/sandbox-manager.ts +++ b/src/e2b/sandbox-manager.ts @@ -605,21 +605,22 @@ export class SandboxManager { this.logger.warn('Using HTTP for NPM registry is insecure. Consider using HTTPS.'); } - // Build registry hostname (including port if present, and path if present) - let registryHost = parsedUrl.host; // includes port if specified - if (parsedUrl.pathname && parsedUrl.pathname !== '/') { - // Remove trailing slash from pathname for consistent format - const cleanPath = parsedUrl.pathname.replace(/\/$/, ''); - registryHost += cleanPath; - } + // Build normalized registry (origin + optional path) without query/fragment + // This ensures auth scope and registry= line use the same normalized value + const cleanPath = parsedUrl.pathname && parsedUrl.pathname !== '/' + ? parsedUrl.pathname.replace(/\/$/, '') + : ''; + const registryHost = parsedUrl.host + cleanPath; + const normalizedRegistry = `${parsedUrl.protocol}//${registryHost}`; // Build .npmrc content + // Use function replacement to avoid $ character interpretation in registryHost const npmrcContent = [ `//registry.npmjs.org/:_authToken=${sanitizedToken}`.replace( 'registry.npmjs.org', - registryHost + () => registryHost ), - `registry=${npmRegistry.replace(/\/$/, '')}` // Remove trailing slash + `registry=${normalizedRegistry}` ].join('\n') + '\n'; // Write .npmrc file diff --git a/tests/e2b/npm-config.test.ts b/tests/e2b/npm-config.test.ts index 56c1d99..fec83c4 100644 --- a/tests/e2b/npm-config.test.ts +++ b/tests/e2b/npm-config.test.ts @@ -406,6 +406,53 @@ describe('NPM Configuration for E2B Sandboxes', () => { ); }); + it('should handle registry hostname with $ character', async () => { + const { SandboxManager } = await import('../../src/e2b/sandbox-manager.js'); + const manager = new SandboxManager(mockLogger as any); + + // Registry with $ in path (edge case for String.replace) + const result = await manager.configureNpmAuth( + mockSandbox as Sandbox, + 'npm_token', + 'https://registry.example.com/$npm' + ); + + expect(result).toBe(true); + // Verify $ is preserved literally, not interpreted as replacement pattern + expect(mockSandbox.files.write).toHaveBeenCalledWith( + '/root/.npmrc', + expect.stringContaining('//registry.example.com/$npm/:_authToken=') + ); + }); + + it('should strip query and fragment from registry URL', async () => { + const { SandboxManager } = await import('../../src/e2b/sandbox-manager.js'); + const manager = new SandboxManager(mockLogger as any); + + // Registry URL with query and fragment (should be stripped) + const result = await manager.configureNpmAuth( + mockSandbox as Sandbox, + 'npm_token', + 'https://registry.example.com/npm?foo=bar#section' + ); + + expect(result).toBe(true); + // Auth scope should not contain query/fragment + expect(mockSandbox.files.write).toHaveBeenCalledWith( + '/root/.npmrc', + expect.stringContaining('//registry.example.com/npm/:_authToken=') + ); + // registry= line should also be normalized + expect(mockSandbox.files.write).toHaveBeenCalledWith( + '/root/.npmrc', + expect.stringContaining('registry=https://registry.example.com/npm') + ); + // Should NOT contain query or fragment + const writeCall = mockSandbox.files.write.mock.calls[0]; + expect(writeCall[1]).not.toContain('?foo=bar'); + expect(writeCall[1]).not.toContain('#section'); + }); + it('should handle token with newlines (sanitization)', async () => { const { SandboxManager } = await import('../../src/e2b/sandbox-manager.js'); const manager = new SandboxManager(mockLogger as any);