@truefoundry/trueforge-core 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +7 -0
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +4 -0
- package/dist/core/index.mjs.map +1 -1
- package/dist/core/llm/VercelAILLM.d.ts.map +1 -1
- package/dist/core/llm/VercelAILLM.js +7 -0
- package/dist/core/llm/VercelAILLM.js.map +1 -1
- package/dist/core/llm/VercelAILLM.mjs +7 -0
- package/dist/core/llm/VercelAILLM.mjs.map +1 -1
- package/dist/core/mcp/RemoteMCP.d.ts +2 -1
- package/dist/core/mcp/RemoteMCP.d.ts.map +1 -1
- package/dist/core/mcp/RemoteMCP.js +65 -20
- package/dist/core/mcp/RemoteMCP.js.map +1 -1
- package/dist/core/mcp/RemoteMCP.mjs +65 -20
- package/dist/core/mcp/RemoteMCP.mjs.map +1 -1
- package/dist/core/mcp/remoteMcpClient.d.ts.map +1 -1
- package/dist/core/mcp/remoteMcpClient.js +2 -5
- package/dist/core/mcp/remoteMcpClient.js.map +1 -1
- package/dist/core/mcp/remoteMcpClient.mjs +2 -5
- package/dist/core/mcp/remoteMcpClient.mjs.map +1 -1
- package/dist/core/sandbox/provider/TFYSandboxProvider.d.ts.map +1 -1
- package/dist/core/sandbox/provider/TFYSandboxProvider.js +19 -6
- package/dist/core/sandbox/provider/TFYSandboxProvider.js.map +1 -1
- package/dist/core/sandbox/provider/TFYSandboxProvider.mjs +19 -7
- package/dist/core/sandbox/provider/TFYSandboxProvider.mjs.map +1 -1
- package/dist/core/util/ssrfGuard.d.ts +10 -0
- package/dist/core/util/ssrfGuard.d.ts.map +1 -0
- package/dist/core/util/ssrfGuard.js +333 -0
- package/dist/core/util/ssrfGuard.js.map +1 -0
- package/dist/core/util/ssrfGuard.mjs +305 -0
- package/dist/core/util/ssrfGuard.mjs.map +1 -0
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../src/core/sandbox/provider/TFYSandboxProvider.ts"],"sourcesContent":["import { context } from '@opentelemetry/api';\nimport { suppressTracing } from '@opentelemetry/core';\nimport dedent from 'dedent';\nimport { randomUUID } from 'node:crypto';\nimport { join } from 'node:path/posix';\nimport type { Logger } from 'winston';\nimport { extractErrorLogFields } from '../../util/errorLogFields';\nimport type { CodeModeTransport } from '../codeMode/CodeModeTransport';\nimport { CodeModeNatsTransport } from '../codeMode/nats/CodeModeNatsTransport';\nimport { DEFAULT_SANDBOX_NATS_WS_PORT } from '../constants';\nimport {\n SandboxFileNotFoundError,\n SandboxFileTooLargeError,\n SandboxPathIsDirectoryError,\n validateSandboxOwnedByTenant,\n} from '../SandboxErrors';\nimport { absolutizeRelativeExecEnv } from './execEnv';\nimport {\n ensureExecSuccess,\n shellEscape,\n type ExecResult,\n type SandboxBuild,\n type SandboxExecParams,\n type SandboxFileInfo,\n type SandboxProvider,\n} from './Provider';\n\nconst DEFAULT_TIMEOUT_SECONDS = 60;\n// Buffer for network latency + response processing on top of the server-side timeout.\nconst CLIENT_TIMEOUT_BUFFER_SECONDS = 5;\n\nconst TFY_MCP_CLIENT_BIN = 'mcp-client/bin';\n\n/** Put the layout CLI first on PATH (cwd-relative; {@link absolutizeRelativeExecEnv} makes it absolute). */\nexport function withMcpClientOnPath(path: string): string {\n const rest = path.split(':').filter(part => part.length > 0 && part !== TFY_MCP_CLIENT_BIN);\n return rest.length === 0 ? TFY_MCP_CLIENT_BIN : `${TFY_MCP_CLIENT_BIN}:${rest.join(':')}`;\n}\n\nfunction parsePwdAndPath(text: string): { root: string; inheritedPath: string } | undefined {\n const lines = text.replace(/\\r\\n/g, '\\n').split('\\n');\n const root = lines[0];\n if (!root?.startsWith('/')) {\n return undefined;\n }\n return { root, inheritedPath: lines[1] ?? '' };\n}\n\nexport interface TFYSandboxProviderOptions {\n serverUrl: string;\n natsBridgeUrl: string;\n tenantName: string;\n fileMaxBytesForDownload: number;\n defaultExecTimeoutMs?: number | undefined;\n logger: Logger;\n}\n\ninterface StatResult {\n size: number;\n type: string;\n}\n\nexport class TFYSandboxProvider implements SandboxProvider {\n readonly type = 'truefoundry';\n private readonly serverUrl: string;\n private readonly natsBridgeUrl: string;\n private readonly tenantName: string;\n private readonly fileMaxBytesForDownload: number;\n private readonly defaultExecTimeoutSeconds: number;\n private readonly logger: Logger;\n /** Discovered via `pwd` + `$PATH` — never a hardcoded host layout. */\n private readonly sandboxLayouts = new Map<string, { root: string; inheritedPath: string }>();\n\n constructor(options: TFYSandboxProviderOptions) {\n this.serverUrl = options.serverUrl.replace(/\\/+$/, '');\n this.natsBridgeUrl = options.natsBridgeUrl;\n this.tenantName = options.tenantName;\n this.fileMaxBytesForDownload = options.fileMaxBytesForDownload;\n this.defaultExecTimeoutSeconds = Math.ceil((options.defaultExecTimeoutMs ?? DEFAULT_TIMEOUT_SECONDS * 1000) / 1000);\n this.logger = options.logger.child({ module: 'TFYSandboxProvider' });\n }\n\n // TFY sandboxes run a prebuilt server image with no per-image build step, so the image is always ready.\n private static readonly readyBuild: SandboxBuild = {\n status: 'ready',\n reason: null,\n metadata: null,\n };\n\n buildImage(): Promise<SandboxBuild> {\n return Promise.resolve(TFYSandboxProvider.readyBuild);\n }\n\n getImageBuildStatus(): Promise<SandboxBuild> {\n return Promise.resolve(TFYSandboxProvider.readyBuild);\n }\n\n createSandbox(): Promise<{ sandboxId: string }> {\n const sandboxId = `${this.tenantName}.${randomUUID()}`;\n this.logger.debug(`Sandbox created: id=${sandboxId}`);\n return Promise.resolve({ sandboxId });\n }\n\n /**\n * No extra FS jail: exec cwd is the logical sandbox root. Layout getters are cwd-relative\n * so writes cannot escape it. `GIT_CONFIG` / PATH / PYTHONPATH must still be absolute so\n * git and `mcp-client` work after `cd` — resolved from `pwd` / `$PATH`, not a hardcoded prefix.\n */\n async exec(params: SandboxExecParams): Promise<ExecResult> {\n validateSandboxOwnedByTenant({ sandboxId: params.sandboxId, tenantName: this.tenantName });\n const { root, inheritedPath } = await this.sandboxLayout(params.sandboxId);\n const incoming = params.env ?? {};\n const env = absolutizeRelativeExecEnv({\n root,\n env: { ...incoming, PATH: withMcpClientOnPath(incoming['PATH'] ?? inheritedPath) },\n });\n return this.postExec({ ...params, env });\n }\n\n private async sandboxLayout(sandboxId: string): Promise<{ root: string; inheritedPath: string }> {\n const cached = this.sandboxLayouts.get(sandboxId);\n if (cached !== undefined) {\n return cached;\n }\n const result = await this.postExec({\n sandboxId,\n command: 'printf \\'%s\\\\n%s\\\\n\\' \"$(pwd)\" \"$PATH\"',\n });\n if (!result.success) {\n throw new Error(`Failed to resolve sandbox working directory: ${result.error}`);\n }\n const parsed = parsePwdAndPath(result.response.result);\n if (parsed === undefined) {\n throw new Error(`sandbox pwd did not return an absolute path: ${result.response.result}`);\n }\n this.sandboxLayouts.set(sandboxId, parsed);\n return parsed;\n }\n\n private async postExec(params: SandboxExecParams): Promise<ExecResult> {\n return context.with(suppressTracing(context.active()), async (): Promise<ExecResult> => {\n // Honor the per-call timeout (e.g. the longer skill-download timeout) for both the server-side\n // request timeout and the client abort timer; fall back to the provider default otherwise.\n const timeoutSeconds = params.timeoutSeconds ?? this.defaultExecTimeoutSeconds;\n const body = {\n sandbox_id: params.sandboxId,\n command: params.command,\n cwd: params.cwd,\n env: params.env,\n timeout: timeoutSeconds,\n };\n\n const controller = new AbortController();\n const clientTimeoutMs = (timeoutSeconds + CLIENT_TIMEOUT_BUFFER_SECONDS) * 1000;\n const timer = setTimeout(() => {\n controller.abort();\n }, clientTimeoutMs);\n\n try {\n const response = await fetch(`${this.serverUrl}/exec`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(body),\n signal: controller.signal,\n });\n\n if (!response.ok) {\n const text = await response.text();\n this.logger.error(`Sandbox server returned ${String(response.status)}: ${text}`);\n return { success: false, error: `Sandbox server returned ${String(response.status)}: ${text}` };\n }\n\n const result = (await response.json()) as ExecResult;\n return result;\n } catch (e: unknown) {\n if (e instanceof Error && e.name === 'AbortError') {\n this.logger.error(`Sandbox exec timed out after ${String(timeoutSeconds)}s`, extractErrorLogFields(e));\n return { success: false, error: `Sandbox exec timed out after ${String(timeoutSeconds)}s` };\n }\n this.logger.error('Sandbox exec failed', extractErrorLogFields(e));\n const message = e instanceof Error ? e.message : 'Unknown error';\n return { success: false, error: message };\n } finally {\n clearTimeout(timer);\n }\n });\n }\n\n private async getFileInfo(params: { sandboxId: string; path: string }): Promise<SandboxFileInfo> {\n const result = await this.exec({\n sandboxId: params.sandboxId,\n command: `stat -L --printf='{\"size\":%s,\"type\":\"%F\"}' ${shellEscape(params.path)}`,\n });\n\n if (!result.success) {\n throw new Error(`Failed to stat file: ${result.error}`);\n }\n if (result.response.exitCode !== 0) {\n throw new SandboxFileNotFoundError(params.path);\n }\n\n const parsed = JSON.parse(result.response.result) as StatResult;\n return { size: parsed.size, isDir: parsed.type === 'directory' };\n }\n\n async downloadFile(params: { sandboxId: string; path: string }): Promise<Buffer> {\n const info = await this.getFileInfo(params);\n if (info.isDir) {\n throw new SandboxPathIsDirectoryError(params.path);\n }\n if (info.size > this.fileMaxBytesForDownload) {\n throw new SandboxFileTooLargeError(params.path, info.size, this.fileMaxBytesForDownload);\n }\n\n const result = await this.exec({\n sandboxId: params.sandboxId,\n command: `base64 -w0 ${shellEscape(params.path)}`,\n });\n\n if (!result.success) {\n throw new Error(`Failed to download file: ${result.error}`);\n }\n if (result.response.exitCode !== 0) {\n throw new SandboxFileNotFoundError(params.path);\n }\n\n return Buffer.from(result.response.result.trim(), 'base64');\n }\n\n async uploadFile(params: { sandboxId: string; remotePath: string; content: Buffer }): Promise<void> {\n const encoded = params.content.toString('base64');\n const escapedPath = shellEscape(params.remotePath);\n\n const result = await this.exec({\n sandboxId: params.sandboxId,\n command: `echo ${shellEscape(encoded)} | base64 -d > ${escapedPath}`,\n });\n ensureExecSuccess(result);\n }\n\n // The TFY sandbox exposes a static, cluster-internal NATS WebSocket URL (no signed URLs).\n createCodeModeTransport(): CodeModeTransport {\n return new CodeModeNatsTransport({\n resolveHostUrl: () => Promise.resolve(this.natsBridgeUrl),\n sandboxClientNatsUrl: `ws://localhost:${String(DEFAULT_SANDBOX_NATS_WS_PORT)}`,\n logger: this.logger,\n mcpClientInstall: { remotePath: join('mcp-client', 'mcp_client.py') },\n });\n }\n\n getAdditionalInstructions(): string {\n return dedent`\n SANDBOX RULES:\n - The Agent's first sandbox command should be \\`pwd\\` to discover the working directory.\n - uploads, skills, and tool-results live in that working directory (not /tmp or /opt).\n - ALL file creation and writes MUST stay within that working directory.\n - The Agent must NOT write to /tmp/, ~/, or any absolute path outside the working directory.\n `;\n }\n\n // Cwd-relative (no FS jail). exec() pwd-joins GIT_CONFIG / PATH / PYTHONPATH.\n // uploads, skills, tool-results, skill_downloader.py, .git-credentials\n // mcp-client/mcp_client.py (no /usr/local/bin symlink)\n getToolResultDumpDir(): string {\n return 'tool-results';\n }\n\n getGitCredentialsPath(): string {\n return '.git-credentials';\n }\n\n getFileUploadsDir(): string {\n return 'uploads';\n }\n\n getSkillsDir(): string {\n return 'skills';\n }\n\n getSkillDownloaderPath(): string {\n return 'skill_downloader.py';\n }\n}\n"],"mappings":"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","names":["dedent"]}
|
|
1
|
+
{"version":3,"sources":["../../../../src/core/sandbox/provider/TFYSandboxProvider.ts"],"sourcesContent":["import { context } from '@opentelemetry/api';\nimport { suppressTracing } from '@opentelemetry/core';\nimport dedent from 'dedent';\nimport { randomUUID } from 'node:crypto';\nimport { join } from 'node:path/posix';\nimport type { Logger } from 'winston';\nimport { extractErrorLogFields } from '../../util/errorLogFields';\nimport type { CodeModeTransport } from '../codeMode/CodeModeTransport';\nimport { CodeModeNatsTransport } from '../codeMode/nats/CodeModeNatsTransport';\nimport { DEFAULT_SANDBOX_NATS_WS_PORT } from '../constants';\nimport {\n SandboxFileNotFoundError,\n SandboxFileTooLargeError,\n SandboxPathIsDirectoryError,\n validateSandboxOwnedByTenant,\n} from '../SandboxErrors';\nimport { absolutizeRelativeExecEnv } from './execEnv';\nimport {\n shellEscape,\n type ExecResult,\n type SandboxBuild,\n type SandboxExecParams,\n type SandboxFileInfo,\n type SandboxProvider,\n} from './Provider';\n\nconst DEFAULT_TIMEOUT_SECONDS = 60;\n// Buffer for network latency + response processing on top of the server-side timeout.\nconst CLIENT_TIMEOUT_BUFFER_SECONDS = 5;\n\n// TFY sandbox file upload timeout (same as Daytona SDK uploadFile default timeout).\nconst FILE_UPLOAD_TIMEOUT_MS = 30 * 60 * 1000;\n\nconst TFY_MCP_CLIENT_BIN = 'mcp-client/bin';\n\n/** Put the layout CLI first on PATH (cwd-relative; {@link absolutizeRelativeExecEnv} makes it absolute). */\nexport function withMcpClientOnPath(path: string): string {\n const rest = path.split(':').filter(part => part.length > 0 && part !== TFY_MCP_CLIENT_BIN);\n return rest.length === 0 ? TFY_MCP_CLIENT_BIN : `${TFY_MCP_CLIENT_BIN}:${rest.join(':')}`;\n}\n\nfunction parsePwdAndPath(text: string): { root: string; inheritedPath: string } | undefined {\n const lines = text.replace(/\\r\\n/g, '\\n').split('\\n');\n const root = lines[0];\n if (!root?.startsWith('/')) {\n return undefined;\n }\n return { root, inheritedPath: lines[1] ?? '' };\n}\n\nexport interface TFYSandboxProviderOptions {\n serverUrl: string;\n natsBridgeUrl: string;\n tenantName: string;\n fileMaxBytesForDownload: number;\n defaultExecTimeoutMs?: number | undefined;\n logger: Logger;\n}\n\ninterface StatResult {\n size: number;\n type: string;\n}\n\nexport class TFYSandboxProvider implements SandboxProvider {\n readonly type = 'truefoundry';\n private readonly serverUrl: string;\n private readonly natsBridgeUrl: string;\n private readonly tenantName: string;\n private readonly fileMaxBytesForDownload: number;\n private readonly defaultExecTimeoutSeconds: number;\n private readonly logger: Logger;\n /** Discovered via `pwd` + `$PATH` — never a hardcoded host layout. */\n private readonly sandboxLayouts = new Map<string, { root: string; inheritedPath: string }>();\n\n constructor(options: TFYSandboxProviderOptions) {\n this.serverUrl = options.serverUrl.replace(/\\/+$/, '');\n this.natsBridgeUrl = options.natsBridgeUrl;\n this.tenantName = options.tenantName;\n this.fileMaxBytesForDownload = options.fileMaxBytesForDownload;\n this.defaultExecTimeoutSeconds = Math.ceil((options.defaultExecTimeoutMs ?? DEFAULT_TIMEOUT_SECONDS * 1000) / 1000);\n this.logger = options.logger.child({ module: 'TFYSandboxProvider' });\n }\n\n // TFY sandboxes run a prebuilt server image with no per-image build step, so the image is always ready.\n private static readonly readyBuild: SandboxBuild = {\n status: 'ready',\n reason: null,\n metadata: null,\n };\n\n buildImage(): Promise<SandboxBuild> {\n return Promise.resolve(TFYSandboxProvider.readyBuild);\n }\n\n getImageBuildStatus(): Promise<SandboxBuild> {\n return Promise.resolve(TFYSandboxProvider.readyBuild);\n }\n\n createSandbox(): Promise<{ sandboxId: string }> {\n const sandboxId = `${this.tenantName}.${randomUUID()}`;\n this.logger.debug(`Sandbox created: id=${sandboxId}`);\n return Promise.resolve({ sandboxId });\n }\n\n /**\n * No extra FS jail: exec cwd is the logical sandbox root. Layout getters are cwd-relative\n * so writes cannot escape it. `GIT_CONFIG` / PATH / PYTHONPATH must still be absolute so\n * git and `mcp-client` work after `cd` — resolved from `pwd` / `$PATH`, not a hardcoded prefix.\n */\n async exec(params: SandboxExecParams): Promise<ExecResult> {\n validateSandboxOwnedByTenant({ sandboxId: params.sandboxId, tenantName: this.tenantName });\n const { root, inheritedPath } = await this.sandboxLayout(params.sandboxId);\n const incoming = params.env ?? {};\n const env = absolutizeRelativeExecEnv({\n root,\n env: { ...incoming, PATH: withMcpClientOnPath(incoming['PATH'] ?? inheritedPath) },\n });\n return this.postExec({ ...params, env });\n }\n\n private async sandboxLayout(sandboxId: string): Promise<{ root: string; inheritedPath: string }> {\n const cached = this.sandboxLayouts.get(sandboxId);\n if (cached !== undefined) {\n return cached;\n }\n const result = await this.postExec({\n sandboxId,\n command: 'printf \\'%s\\\\n%s\\\\n\\' \"$(pwd)\" \"$PATH\"',\n });\n if (!result.success) {\n throw new Error(`Failed to resolve sandbox working directory: ${result.error}`);\n }\n const parsed = parsePwdAndPath(result.response.result);\n if (parsed === undefined) {\n throw new Error(`sandbox pwd did not return an absolute path: ${result.response.result}`);\n }\n this.sandboxLayouts.set(sandboxId, parsed);\n return parsed;\n }\n\n private async postExec(params: SandboxExecParams): Promise<ExecResult> {\n return context.with(suppressTracing(context.active()), async (): Promise<ExecResult> => {\n // Honor the per-call timeout (e.g. the longer skill-download timeout) for both the server-side\n // request timeout and the client abort timer; fall back to the provider default otherwise.\n const timeoutSeconds = params.timeoutSeconds ?? this.defaultExecTimeoutSeconds;\n const body = {\n sandbox_id: params.sandboxId,\n command: params.command,\n cwd: params.cwd,\n env: params.env,\n timeout: timeoutSeconds,\n };\n\n const controller = new AbortController();\n const clientTimeoutMs = (timeoutSeconds + CLIENT_TIMEOUT_BUFFER_SECONDS) * 1000;\n const timer = setTimeout(() => {\n controller.abort();\n }, clientTimeoutMs);\n\n try {\n const response = await fetch(`${this.serverUrl}/exec`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(body),\n signal: controller.signal,\n });\n\n if (!response.ok) {\n const text = await response.text();\n this.logger.error(`Sandbox server returned ${String(response.status)}: ${text}`);\n return { success: false, error: `Sandbox server returned ${String(response.status)}: ${text}` };\n }\n\n const result = (await response.json()) as ExecResult;\n return result;\n } catch (e: unknown) {\n if (e instanceof Error && e.name === 'AbortError') {\n this.logger.error(`Sandbox exec timed out after ${String(timeoutSeconds)}s`, extractErrorLogFields(e));\n return { success: false, error: `Sandbox exec timed out after ${String(timeoutSeconds)}s` };\n }\n this.logger.error('Sandbox exec failed', extractErrorLogFields(e));\n const message = e instanceof Error ? e.message : 'Unknown error';\n return { success: false, error: message };\n } finally {\n clearTimeout(timer);\n }\n });\n }\n\n private async getFileInfo(params: { sandboxId: string; path: string }): Promise<SandboxFileInfo> {\n const result = await this.exec({\n sandboxId: params.sandboxId,\n command: `stat -L --printf='{\"size\":%s,\"type\":\"%F\"}' ${shellEscape(params.path)}`,\n });\n\n if (!result.success) {\n throw new Error(`Failed to stat file: ${result.error}`);\n }\n if (result.response.exitCode !== 0) {\n throw new SandboxFileNotFoundError(params.path);\n }\n\n const parsed = JSON.parse(result.response.result) as StatResult;\n return { size: parsed.size, isDir: parsed.type === 'directory' };\n }\n\n async downloadFile(params: { sandboxId: string; path: string }): Promise<Buffer> {\n const info = await this.getFileInfo(params);\n if (info.isDir) {\n throw new SandboxPathIsDirectoryError(params.path);\n }\n if (info.size > this.fileMaxBytesForDownload) {\n throw new SandboxFileTooLargeError(params.path, info.size, this.fileMaxBytesForDownload);\n }\n\n const result = await this.exec({\n sandboxId: params.sandboxId,\n command: `base64 -w0 ${shellEscape(params.path)}`,\n });\n\n if (!result.success) {\n throw new Error(`Failed to download file: ${result.error}`);\n }\n if (result.response.exitCode !== 0) {\n throw new SandboxFileNotFoundError(params.path);\n }\n\n return Buffer.from(result.response.result.trim(), 'base64');\n }\n\n async uploadFile(params: { sandboxId: string; remotePath: string; content: Buffer }): Promise<void> {\n validateSandboxOwnedByTenant({ sandboxId: params.sandboxId, tenantName: this.tenantName });\n\n return context.with(suppressTracing(context.active()), async () => {\n const query = new URLSearchParams({ sandbox_id: params.sandboxId, path: params.remotePath });\n const response = await fetch(`${this.serverUrl}/files/upload?${query.toString()}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/octet-stream' },\n body: params.content,\n signal: AbortSignal.timeout(FILE_UPLOAD_TIMEOUT_MS),\n });\n if (!response.ok) {\n throw new Error(\n `File upload to sandbox failed: Sandbox server returned ${String(response.status)}: ${await response.text()}`,\n );\n }\n\n const result = (await response.json()) as { success: true } | { success: false; error: string };\n if (!result.success) {\n throw new Error(`File upload to sandbox failed: ${result.error}`);\n }\n });\n }\n\n // The TFY sandbox exposes a static, cluster-internal NATS WebSocket URL (no signed URLs).\n createCodeModeTransport(): CodeModeTransport {\n return new CodeModeNatsTransport({\n resolveHostUrl: () => Promise.resolve(this.natsBridgeUrl),\n sandboxClientNatsUrl: `ws://localhost:${String(DEFAULT_SANDBOX_NATS_WS_PORT)}`,\n logger: this.logger,\n mcpClientInstall: { remotePath: join('mcp-client', 'mcp_client.py') },\n });\n }\n\n getAdditionalInstructions(): string {\n return dedent`\n SANDBOX RULES:\n - The Agent's first sandbox command should be \\`pwd\\` to discover the working directory.\n - uploads, skills, and tool-results live in that working directory (not /tmp or /opt).\n - ALL file creation and writes MUST stay within that working directory.\n - The Agent must NOT write to /tmp/, ~/, or any absolute path outside the working directory.\n `;\n }\n\n // Cwd-relative (no FS jail). exec() pwd-joins GIT_CONFIG / PATH / PYTHONPATH.\n // uploads, skills, tool-results, skill_downloader.py, .git-credentials\n // mcp-client/mcp_client.py (no /usr/local/bin symlink)\n getToolResultDumpDir(): string {\n return 'tool-results';\n }\n\n getGitCredentialsPath(): string {\n return '.git-credentials';\n }\n\n getFileUploadsDir(): string {\n return 'uploads';\n }\n\n getSkillsDir(): string {\n return 'skills';\n }\n\n getSkillDownloaderPath(): string {\n return 'skill_downloader.py';\n }\n}\n"],"mappings":"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","names":["dedent"]}
|
|
@@ -15,11 +15,11 @@ import {
|
|
|
15
15
|
} from "../SandboxErrors.mjs";
|
|
16
16
|
import { absolutizeRelativeExecEnv } from "./execEnv.mjs";
|
|
17
17
|
import {
|
|
18
|
-
ensureExecSuccess,
|
|
19
18
|
shellEscape
|
|
20
19
|
} from "./Provider.mjs";
|
|
21
20
|
var DEFAULT_TIMEOUT_SECONDS = 60;
|
|
22
21
|
var CLIENT_TIMEOUT_BUFFER_SECONDS = 5;
|
|
22
|
+
var FILE_UPLOAD_TIMEOUT_MS = 30 * 60 * 1e3;
|
|
23
23
|
var TFY_MCP_CLIENT_BIN = "mcp-client/bin";
|
|
24
24
|
function withMcpClientOnPath(path) {
|
|
25
25
|
const rest = path.split(":").filter((part) => part.length > 0 && part !== TFY_MCP_CLIENT_BIN);
|
|
@@ -179,13 +179,25 @@ var TFYSandboxProvider = class _TFYSandboxProvider {
|
|
|
179
179
|
return Buffer.from(result.response.result.trim(), "base64");
|
|
180
180
|
}
|
|
181
181
|
async uploadFile(params) {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
182
|
+
validateSandboxOwnedByTenant({ sandboxId: params.sandboxId, tenantName: this.tenantName });
|
|
183
|
+
return context.with(suppressTracing(context.active()), async () => {
|
|
184
|
+
const query = new URLSearchParams({ sandbox_id: params.sandboxId, path: params.remotePath });
|
|
185
|
+
const response = await fetch(`${this.serverUrl}/files/upload?${query.toString()}`, {
|
|
186
|
+
method: "POST",
|
|
187
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
188
|
+
body: params.content,
|
|
189
|
+
signal: AbortSignal.timeout(FILE_UPLOAD_TIMEOUT_MS)
|
|
190
|
+
});
|
|
191
|
+
if (!response.ok) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
`File upload to sandbox failed: Sandbox server returned ${String(response.status)}: ${await response.text()}`
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
const result = await response.json();
|
|
197
|
+
if (!result.success) {
|
|
198
|
+
throw new Error(`File upload to sandbox failed: ${result.error}`);
|
|
199
|
+
}
|
|
187
200
|
});
|
|
188
|
-
ensureExecSuccess(result);
|
|
189
201
|
}
|
|
190
202
|
// The TFY sandbox exposes a static, cluster-internal NATS WebSocket URL (no signed URLs).
|
|
191
203
|
createCodeModeTransport() {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../src/core/sandbox/provider/TFYSandboxProvider.ts"],"sourcesContent":["import { context } from '@opentelemetry/api';\nimport { suppressTracing } from '@opentelemetry/core';\nimport dedent from 'dedent';\nimport { randomUUID } from 'node:crypto';\nimport { join } from 'node:path/posix';\nimport type { Logger } from 'winston';\nimport { extractErrorLogFields } from '../../util/errorLogFields';\nimport type { CodeModeTransport } from '../codeMode/CodeModeTransport';\nimport { CodeModeNatsTransport } from '../codeMode/nats/CodeModeNatsTransport';\nimport { DEFAULT_SANDBOX_NATS_WS_PORT } from '../constants';\nimport {\n SandboxFileNotFoundError,\n SandboxFileTooLargeError,\n SandboxPathIsDirectoryError,\n validateSandboxOwnedByTenant,\n} from '../SandboxErrors';\nimport { absolutizeRelativeExecEnv } from './execEnv';\nimport {\n ensureExecSuccess,\n shellEscape,\n type ExecResult,\n type SandboxBuild,\n type SandboxExecParams,\n type SandboxFileInfo,\n type SandboxProvider,\n} from './Provider';\n\nconst DEFAULT_TIMEOUT_SECONDS = 60;\n// Buffer for network latency + response processing on top of the server-side timeout.\nconst CLIENT_TIMEOUT_BUFFER_SECONDS = 5;\n\nconst TFY_MCP_CLIENT_BIN = 'mcp-client/bin';\n\n/** Put the layout CLI first on PATH (cwd-relative; {@link absolutizeRelativeExecEnv} makes it absolute). */\nexport function withMcpClientOnPath(path: string): string {\n const rest = path.split(':').filter(part => part.length > 0 && part !== TFY_MCP_CLIENT_BIN);\n return rest.length === 0 ? TFY_MCP_CLIENT_BIN : `${TFY_MCP_CLIENT_BIN}:${rest.join(':')}`;\n}\n\nfunction parsePwdAndPath(text: string): { root: string; inheritedPath: string } | undefined {\n const lines = text.replace(/\\r\\n/g, '\\n').split('\\n');\n const root = lines[0];\n if (!root?.startsWith('/')) {\n return undefined;\n }\n return { root, inheritedPath: lines[1] ?? '' };\n}\n\nexport interface TFYSandboxProviderOptions {\n serverUrl: string;\n natsBridgeUrl: string;\n tenantName: string;\n fileMaxBytesForDownload: number;\n defaultExecTimeoutMs?: number | undefined;\n logger: Logger;\n}\n\ninterface StatResult {\n size: number;\n type: string;\n}\n\nexport class TFYSandboxProvider implements SandboxProvider {\n readonly type = 'truefoundry';\n private readonly serverUrl: string;\n private readonly natsBridgeUrl: string;\n private readonly tenantName: string;\n private readonly fileMaxBytesForDownload: number;\n private readonly defaultExecTimeoutSeconds: number;\n private readonly logger: Logger;\n /** Discovered via `pwd` + `$PATH` — never a hardcoded host layout. */\n private readonly sandboxLayouts = new Map<string, { root: string; inheritedPath: string }>();\n\n constructor(options: TFYSandboxProviderOptions) {\n this.serverUrl = options.serverUrl.replace(/\\/+$/, '');\n this.natsBridgeUrl = options.natsBridgeUrl;\n this.tenantName = options.tenantName;\n this.fileMaxBytesForDownload = options.fileMaxBytesForDownload;\n this.defaultExecTimeoutSeconds = Math.ceil((options.defaultExecTimeoutMs ?? DEFAULT_TIMEOUT_SECONDS * 1000) / 1000);\n this.logger = options.logger.child({ module: 'TFYSandboxProvider' });\n }\n\n // TFY sandboxes run a prebuilt server image with no per-image build step, so the image is always ready.\n private static readonly readyBuild: SandboxBuild = {\n status: 'ready',\n reason: null,\n metadata: null,\n };\n\n buildImage(): Promise<SandboxBuild> {\n return Promise.resolve(TFYSandboxProvider.readyBuild);\n }\n\n getImageBuildStatus(): Promise<SandboxBuild> {\n return Promise.resolve(TFYSandboxProvider.readyBuild);\n }\n\n createSandbox(): Promise<{ sandboxId: string }> {\n const sandboxId = `${this.tenantName}.${randomUUID()}`;\n this.logger.debug(`Sandbox created: id=${sandboxId}`);\n return Promise.resolve({ sandboxId });\n }\n\n /**\n * No extra FS jail: exec cwd is the logical sandbox root. Layout getters are cwd-relative\n * so writes cannot escape it. `GIT_CONFIG` / PATH / PYTHONPATH must still be absolute so\n * git and `mcp-client` work after `cd` — resolved from `pwd` / `$PATH`, not a hardcoded prefix.\n */\n async exec(params: SandboxExecParams): Promise<ExecResult> {\n validateSandboxOwnedByTenant({ sandboxId: params.sandboxId, tenantName: this.tenantName });\n const { root, inheritedPath } = await this.sandboxLayout(params.sandboxId);\n const incoming = params.env ?? {};\n const env = absolutizeRelativeExecEnv({\n root,\n env: { ...incoming, PATH: withMcpClientOnPath(incoming['PATH'] ?? inheritedPath) },\n });\n return this.postExec({ ...params, env });\n }\n\n private async sandboxLayout(sandboxId: string): Promise<{ root: string; inheritedPath: string }> {\n const cached = this.sandboxLayouts.get(sandboxId);\n if (cached !== undefined) {\n return cached;\n }\n const result = await this.postExec({\n sandboxId,\n command: 'printf \\'%s\\\\n%s\\\\n\\' \"$(pwd)\" \"$PATH\"',\n });\n if (!result.success) {\n throw new Error(`Failed to resolve sandbox working directory: ${result.error}`);\n }\n const parsed = parsePwdAndPath(result.response.result);\n if (parsed === undefined) {\n throw new Error(`sandbox pwd did not return an absolute path: ${result.response.result}`);\n }\n this.sandboxLayouts.set(sandboxId, parsed);\n return parsed;\n }\n\n private async postExec(params: SandboxExecParams): Promise<ExecResult> {\n return context.with(suppressTracing(context.active()), async (): Promise<ExecResult> => {\n // Honor the per-call timeout (e.g. the longer skill-download timeout) for both the server-side\n // request timeout and the client abort timer; fall back to the provider default otherwise.\n const timeoutSeconds = params.timeoutSeconds ?? this.defaultExecTimeoutSeconds;\n const body = {\n sandbox_id: params.sandboxId,\n command: params.command,\n cwd: params.cwd,\n env: params.env,\n timeout: timeoutSeconds,\n };\n\n const controller = new AbortController();\n const clientTimeoutMs = (timeoutSeconds + CLIENT_TIMEOUT_BUFFER_SECONDS) * 1000;\n const timer = setTimeout(() => {\n controller.abort();\n }, clientTimeoutMs);\n\n try {\n const response = await fetch(`${this.serverUrl}/exec`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(body),\n signal: controller.signal,\n });\n\n if (!response.ok) {\n const text = await response.text();\n this.logger.error(`Sandbox server returned ${String(response.status)}: ${text}`);\n return { success: false, error: `Sandbox server returned ${String(response.status)}: ${text}` };\n }\n\n const result = (await response.json()) as ExecResult;\n return result;\n } catch (e: unknown) {\n if (e instanceof Error && e.name === 'AbortError') {\n this.logger.error(`Sandbox exec timed out after ${String(timeoutSeconds)}s`, extractErrorLogFields(e));\n return { success: false, error: `Sandbox exec timed out after ${String(timeoutSeconds)}s` };\n }\n this.logger.error('Sandbox exec failed', extractErrorLogFields(e));\n const message = e instanceof Error ? e.message : 'Unknown error';\n return { success: false, error: message };\n } finally {\n clearTimeout(timer);\n }\n });\n }\n\n private async getFileInfo(params: { sandboxId: string; path: string }): Promise<SandboxFileInfo> {\n const result = await this.exec({\n sandboxId: params.sandboxId,\n command: `stat -L --printf='{\"size\":%s,\"type\":\"%F\"}' ${shellEscape(params.path)}`,\n });\n\n if (!result.success) {\n throw new Error(`Failed to stat file: ${result.error}`);\n }\n if (result.response.exitCode !== 0) {\n throw new SandboxFileNotFoundError(params.path);\n }\n\n const parsed = JSON.parse(result.response.result) as StatResult;\n return { size: parsed.size, isDir: parsed.type === 'directory' };\n }\n\n async downloadFile(params: { sandboxId: string; path: string }): Promise<Buffer> {\n const info = await this.getFileInfo(params);\n if (info.isDir) {\n throw new SandboxPathIsDirectoryError(params.path);\n }\n if (info.size > this.fileMaxBytesForDownload) {\n throw new SandboxFileTooLargeError(params.path, info.size, this.fileMaxBytesForDownload);\n }\n\n const result = await this.exec({\n sandboxId: params.sandboxId,\n command: `base64 -w0 ${shellEscape(params.path)}`,\n });\n\n if (!result.success) {\n throw new Error(`Failed to download file: ${result.error}`);\n }\n if (result.response.exitCode !== 0) {\n throw new SandboxFileNotFoundError(params.path);\n }\n\n return Buffer.from(result.response.result.trim(), 'base64');\n }\n\n async uploadFile(params: { sandboxId: string; remotePath: string; content: Buffer }): Promise<void> {\n const encoded = params.content.toString('base64');\n const escapedPath = shellEscape(params.remotePath);\n\n const result = await this.exec({\n sandboxId: params.sandboxId,\n command: `echo ${shellEscape(encoded)} | base64 -d > ${escapedPath}`,\n });\n ensureExecSuccess(result);\n }\n\n // The TFY sandbox exposes a static, cluster-internal NATS WebSocket URL (no signed URLs).\n createCodeModeTransport(): CodeModeTransport {\n return new CodeModeNatsTransport({\n resolveHostUrl: () => Promise.resolve(this.natsBridgeUrl),\n sandboxClientNatsUrl: `ws://localhost:${String(DEFAULT_SANDBOX_NATS_WS_PORT)}`,\n logger: this.logger,\n mcpClientInstall: { remotePath: join('mcp-client', 'mcp_client.py') },\n });\n }\n\n getAdditionalInstructions(): string {\n return dedent`\n SANDBOX RULES:\n - The Agent's first sandbox command should be \\`pwd\\` to discover the working directory.\n - uploads, skills, and tool-results live in that working directory (not /tmp or /opt).\n - ALL file creation and writes MUST stay within that working directory.\n - The Agent must NOT write to /tmp/, ~/, or any absolute path outside the working directory.\n `;\n }\n\n // Cwd-relative (no FS jail). exec() pwd-joins GIT_CONFIG / PATH / PYTHONPATH.\n // uploads, skills, tool-results, skill_downloader.py, .git-credentials\n // mcp-client/mcp_client.py (no /usr/local/bin symlink)\n getToolResultDumpDir(): string {\n return 'tool-results';\n }\n\n getGitCredentialsPath(): string {\n return '.git-credentials';\n }\n\n getFileUploadsDir(): string {\n return 'uploads';\n }\n\n getSkillsDir(): string {\n return 'skills';\n }\n\n getSkillDownloaderPath(): string {\n return 'skill_downloader.py';\n }\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../../../src/core/sandbox/provider/TFYSandboxProvider.ts"],"sourcesContent":["import { context } from '@opentelemetry/api';\nimport { suppressTracing } from '@opentelemetry/core';\nimport dedent from 'dedent';\nimport { randomUUID } from 'node:crypto';\nimport { join } from 'node:path/posix';\nimport type { Logger } from 'winston';\nimport { extractErrorLogFields } from '../../util/errorLogFields';\nimport type { CodeModeTransport } from '../codeMode/CodeModeTransport';\nimport { CodeModeNatsTransport } from '../codeMode/nats/CodeModeNatsTransport';\nimport { DEFAULT_SANDBOX_NATS_WS_PORT } from '../constants';\nimport {\n SandboxFileNotFoundError,\n SandboxFileTooLargeError,\n SandboxPathIsDirectoryError,\n validateSandboxOwnedByTenant,\n} from '../SandboxErrors';\nimport { absolutizeRelativeExecEnv } from './execEnv';\nimport {\n shellEscape,\n type ExecResult,\n type SandboxBuild,\n type SandboxExecParams,\n type SandboxFileInfo,\n type SandboxProvider,\n} from './Provider';\n\nconst DEFAULT_TIMEOUT_SECONDS = 60;\n// Buffer for network latency + response processing on top of the server-side timeout.\nconst CLIENT_TIMEOUT_BUFFER_SECONDS = 5;\n\n// TFY sandbox file upload timeout (same as Daytona SDK uploadFile default timeout).\nconst FILE_UPLOAD_TIMEOUT_MS = 30 * 60 * 1000;\n\nconst TFY_MCP_CLIENT_BIN = 'mcp-client/bin';\n\n/** Put the layout CLI first on PATH (cwd-relative; {@link absolutizeRelativeExecEnv} makes it absolute). */\nexport function withMcpClientOnPath(path: string): string {\n const rest = path.split(':').filter(part => part.length > 0 && part !== TFY_MCP_CLIENT_BIN);\n return rest.length === 0 ? TFY_MCP_CLIENT_BIN : `${TFY_MCP_CLIENT_BIN}:${rest.join(':')}`;\n}\n\nfunction parsePwdAndPath(text: string): { root: string; inheritedPath: string } | undefined {\n const lines = text.replace(/\\r\\n/g, '\\n').split('\\n');\n const root = lines[0];\n if (!root?.startsWith('/')) {\n return undefined;\n }\n return { root, inheritedPath: lines[1] ?? '' };\n}\n\nexport interface TFYSandboxProviderOptions {\n serverUrl: string;\n natsBridgeUrl: string;\n tenantName: string;\n fileMaxBytesForDownload: number;\n defaultExecTimeoutMs?: number | undefined;\n logger: Logger;\n}\n\ninterface StatResult {\n size: number;\n type: string;\n}\n\nexport class TFYSandboxProvider implements SandboxProvider {\n readonly type = 'truefoundry';\n private readonly serverUrl: string;\n private readonly natsBridgeUrl: string;\n private readonly tenantName: string;\n private readonly fileMaxBytesForDownload: number;\n private readonly defaultExecTimeoutSeconds: number;\n private readonly logger: Logger;\n /** Discovered via `pwd` + `$PATH` — never a hardcoded host layout. */\n private readonly sandboxLayouts = new Map<string, { root: string; inheritedPath: string }>();\n\n constructor(options: TFYSandboxProviderOptions) {\n this.serverUrl = options.serverUrl.replace(/\\/+$/, '');\n this.natsBridgeUrl = options.natsBridgeUrl;\n this.tenantName = options.tenantName;\n this.fileMaxBytesForDownload = options.fileMaxBytesForDownload;\n this.defaultExecTimeoutSeconds = Math.ceil((options.defaultExecTimeoutMs ?? DEFAULT_TIMEOUT_SECONDS * 1000) / 1000);\n this.logger = options.logger.child({ module: 'TFYSandboxProvider' });\n }\n\n // TFY sandboxes run a prebuilt server image with no per-image build step, so the image is always ready.\n private static readonly readyBuild: SandboxBuild = {\n status: 'ready',\n reason: null,\n metadata: null,\n };\n\n buildImage(): Promise<SandboxBuild> {\n return Promise.resolve(TFYSandboxProvider.readyBuild);\n }\n\n getImageBuildStatus(): Promise<SandboxBuild> {\n return Promise.resolve(TFYSandboxProvider.readyBuild);\n }\n\n createSandbox(): Promise<{ sandboxId: string }> {\n const sandboxId = `${this.tenantName}.${randomUUID()}`;\n this.logger.debug(`Sandbox created: id=${sandboxId}`);\n return Promise.resolve({ sandboxId });\n }\n\n /**\n * No extra FS jail: exec cwd is the logical sandbox root. Layout getters are cwd-relative\n * so writes cannot escape it. `GIT_CONFIG` / PATH / PYTHONPATH must still be absolute so\n * git and `mcp-client` work after `cd` — resolved from `pwd` / `$PATH`, not a hardcoded prefix.\n */\n async exec(params: SandboxExecParams): Promise<ExecResult> {\n validateSandboxOwnedByTenant({ sandboxId: params.sandboxId, tenantName: this.tenantName });\n const { root, inheritedPath } = await this.sandboxLayout(params.sandboxId);\n const incoming = params.env ?? {};\n const env = absolutizeRelativeExecEnv({\n root,\n env: { ...incoming, PATH: withMcpClientOnPath(incoming['PATH'] ?? inheritedPath) },\n });\n return this.postExec({ ...params, env });\n }\n\n private async sandboxLayout(sandboxId: string): Promise<{ root: string; inheritedPath: string }> {\n const cached = this.sandboxLayouts.get(sandboxId);\n if (cached !== undefined) {\n return cached;\n }\n const result = await this.postExec({\n sandboxId,\n command: 'printf \\'%s\\\\n%s\\\\n\\' \"$(pwd)\" \"$PATH\"',\n });\n if (!result.success) {\n throw new Error(`Failed to resolve sandbox working directory: ${result.error}`);\n }\n const parsed = parsePwdAndPath(result.response.result);\n if (parsed === undefined) {\n throw new Error(`sandbox pwd did not return an absolute path: ${result.response.result}`);\n }\n this.sandboxLayouts.set(sandboxId, parsed);\n return parsed;\n }\n\n private async postExec(params: SandboxExecParams): Promise<ExecResult> {\n return context.with(suppressTracing(context.active()), async (): Promise<ExecResult> => {\n // Honor the per-call timeout (e.g. the longer skill-download timeout) for both the server-side\n // request timeout and the client abort timer; fall back to the provider default otherwise.\n const timeoutSeconds = params.timeoutSeconds ?? this.defaultExecTimeoutSeconds;\n const body = {\n sandbox_id: params.sandboxId,\n command: params.command,\n cwd: params.cwd,\n env: params.env,\n timeout: timeoutSeconds,\n };\n\n const controller = new AbortController();\n const clientTimeoutMs = (timeoutSeconds + CLIENT_TIMEOUT_BUFFER_SECONDS) * 1000;\n const timer = setTimeout(() => {\n controller.abort();\n }, clientTimeoutMs);\n\n try {\n const response = await fetch(`${this.serverUrl}/exec`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(body),\n signal: controller.signal,\n });\n\n if (!response.ok) {\n const text = await response.text();\n this.logger.error(`Sandbox server returned ${String(response.status)}: ${text}`);\n return { success: false, error: `Sandbox server returned ${String(response.status)}: ${text}` };\n }\n\n const result = (await response.json()) as ExecResult;\n return result;\n } catch (e: unknown) {\n if (e instanceof Error && e.name === 'AbortError') {\n this.logger.error(`Sandbox exec timed out after ${String(timeoutSeconds)}s`, extractErrorLogFields(e));\n return { success: false, error: `Sandbox exec timed out after ${String(timeoutSeconds)}s` };\n }\n this.logger.error('Sandbox exec failed', extractErrorLogFields(e));\n const message = e instanceof Error ? e.message : 'Unknown error';\n return { success: false, error: message };\n } finally {\n clearTimeout(timer);\n }\n });\n }\n\n private async getFileInfo(params: { sandboxId: string; path: string }): Promise<SandboxFileInfo> {\n const result = await this.exec({\n sandboxId: params.sandboxId,\n command: `stat -L --printf='{\"size\":%s,\"type\":\"%F\"}' ${shellEscape(params.path)}`,\n });\n\n if (!result.success) {\n throw new Error(`Failed to stat file: ${result.error}`);\n }\n if (result.response.exitCode !== 0) {\n throw new SandboxFileNotFoundError(params.path);\n }\n\n const parsed = JSON.parse(result.response.result) as StatResult;\n return { size: parsed.size, isDir: parsed.type === 'directory' };\n }\n\n async downloadFile(params: { sandboxId: string; path: string }): Promise<Buffer> {\n const info = await this.getFileInfo(params);\n if (info.isDir) {\n throw new SandboxPathIsDirectoryError(params.path);\n }\n if (info.size > this.fileMaxBytesForDownload) {\n throw new SandboxFileTooLargeError(params.path, info.size, this.fileMaxBytesForDownload);\n }\n\n const result = await this.exec({\n sandboxId: params.sandboxId,\n command: `base64 -w0 ${shellEscape(params.path)}`,\n });\n\n if (!result.success) {\n throw new Error(`Failed to download file: ${result.error}`);\n }\n if (result.response.exitCode !== 0) {\n throw new SandboxFileNotFoundError(params.path);\n }\n\n return Buffer.from(result.response.result.trim(), 'base64');\n }\n\n async uploadFile(params: { sandboxId: string; remotePath: string; content: Buffer }): Promise<void> {\n validateSandboxOwnedByTenant({ sandboxId: params.sandboxId, tenantName: this.tenantName });\n\n return context.with(suppressTracing(context.active()), async () => {\n const query = new URLSearchParams({ sandbox_id: params.sandboxId, path: params.remotePath });\n const response = await fetch(`${this.serverUrl}/files/upload?${query.toString()}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/octet-stream' },\n body: params.content,\n signal: AbortSignal.timeout(FILE_UPLOAD_TIMEOUT_MS),\n });\n if (!response.ok) {\n throw new Error(\n `File upload to sandbox failed: Sandbox server returned ${String(response.status)}: ${await response.text()}`,\n );\n }\n\n const result = (await response.json()) as { success: true } | { success: false; error: string };\n if (!result.success) {\n throw new Error(`File upload to sandbox failed: ${result.error}`);\n }\n });\n }\n\n // The TFY sandbox exposes a static, cluster-internal NATS WebSocket URL (no signed URLs).\n createCodeModeTransport(): CodeModeTransport {\n return new CodeModeNatsTransport({\n resolveHostUrl: () => Promise.resolve(this.natsBridgeUrl),\n sandboxClientNatsUrl: `ws://localhost:${String(DEFAULT_SANDBOX_NATS_WS_PORT)}`,\n logger: this.logger,\n mcpClientInstall: { remotePath: join('mcp-client', 'mcp_client.py') },\n });\n }\n\n getAdditionalInstructions(): string {\n return dedent`\n SANDBOX RULES:\n - The Agent's first sandbox command should be \\`pwd\\` to discover the working directory.\n - uploads, skills, and tool-results live in that working directory (not /tmp or /opt).\n - ALL file creation and writes MUST stay within that working directory.\n - The Agent must NOT write to /tmp/, ~/, or any absolute path outside the working directory.\n `;\n }\n\n // Cwd-relative (no FS jail). exec() pwd-joins GIT_CONFIG / PATH / PYTHONPATH.\n // uploads, skills, tool-results, skill_downloader.py, .git-credentials\n // mcp-client/mcp_client.py (no /usr/local/bin symlink)\n getToolResultDumpDir(): string {\n return 'tool-results';\n }\n\n getGitCredentialsPath(): string {\n return '.git-credentials';\n }\n\n getFileUploadsDir(): string {\n return 'uploads';\n }\n\n getSkillsDir(): string {\n return 'skills';\n }\n\n getSkillDownloaderPath(): string {\n return 'skill_downloader.py';\n }\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare function configureOutboundUrlGuard(config: {
|
|
2
|
+
enabled?: boolean;
|
|
3
|
+
allowedHosts: readonly string[];
|
|
4
|
+
blockedHosts: readonly string[];
|
|
5
|
+
}): void;
|
|
6
|
+
export declare function assertSafeOutboundUrl(input: string | URL | Request): Promise<void>;
|
|
7
|
+
export declare function ssrfFetch(input: string | URL | Request, init?: RequestInit): Promise<Response>;
|
|
8
|
+
/** Same as `ssrfFetch` with a 30m bodyTimeout for idle MCP SSE / streamable-HTTP. */
|
|
9
|
+
export declare function mcpSsrfFetch(input: string | URL | Request, init?: RequestInit): Promise<Response>;
|
|
10
|
+
//# sourceMappingURL=ssrfGuard.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ssrfGuard.d.ts","sourceRoot":"","sources":["../../../src/core/util/ssrfGuard.ts"],"names":[],"mappings":"AA2EA,wBAAgB,yBAAyB,CAAC,MAAM,EAAE;IAChD,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC;IAChC,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC;CACjC,GAAG,IAAI,CAIP;AAyGD,wBAAsB,qBAAqB,CAAC,KAAK,EAAE,MAAM,GAAG,GAAG,GAAG,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAmBxF;AAmGD,wBAAsB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,GAAG,GAAG,OAAO,EAAE,IAAI,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,QAAQ,CAAC,CAEpG;AAED,qFAAqF;AACrF,wBAAsB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,GAAG,GAAG,OAAO,EAAE,IAAI,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,QAAQ,CAAC,CAEvG"}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/core/util/ssrfGuard.ts
|
|
21
|
+
var ssrfGuard_exports = {};
|
|
22
|
+
__export(ssrfGuard_exports, {
|
|
23
|
+
assertSafeOutboundUrl: () => assertSafeOutboundUrl,
|
|
24
|
+
configureOutboundUrlGuard: () => configureOutboundUrlGuard,
|
|
25
|
+
mcpSsrfFetch: () => mcpSsrfFetch,
|
|
26
|
+
ssrfFetch: () => ssrfFetch
|
|
27
|
+
});
|
|
28
|
+
module.exports = __toCommonJS(ssrfGuard_exports);
|
|
29
|
+
var import_node_dns = require("dns");
|
|
30
|
+
var import_promises = require("dns/promises");
|
|
31
|
+
var import_node_net = require("net");
|
|
32
|
+
var import_undici = require("undici");
|
|
33
|
+
var allowedHosts = [];
|
|
34
|
+
var blockedHosts = [];
|
|
35
|
+
var guardEnabled = true;
|
|
36
|
+
var URL_VERIFY = {
|
|
37
|
+
allowedProtocols: ["http:", "https:"],
|
|
38
|
+
denyCidrsV4: [
|
|
39
|
+
"0.0.0.0/8",
|
|
40
|
+
// this host
|
|
41
|
+
"10.0.0.0/8",
|
|
42
|
+
// private
|
|
43
|
+
"100.64.0.0/10",
|
|
44
|
+
// CGNAT (EKS secondary pod CIDRs)
|
|
45
|
+
"127.0.0.0/8",
|
|
46
|
+
// loopback
|
|
47
|
+
"169.254.0.0/16",
|
|
48
|
+
// link-local + metadata
|
|
49
|
+
"172.16.0.0/12",
|
|
50
|
+
// private (docker, k8s service CIDRs)
|
|
51
|
+
"192.0.0.0/24",
|
|
52
|
+
// IETF protocol assignments
|
|
53
|
+
"192.0.2.0/24",
|
|
54
|
+
// TEST-NET-1
|
|
55
|
+
"192.88.99.0/24",
|
|
56
|
+
// 6to4 relay anycast
|
|
57
|
+
"192.168.0.0/16",
|
|
58
|
+
// private
|
|
59
|
+
"198.18.0.0/15",
|
|
60
|
+
// benchmarking
|
|
61
|
+
"198.51.100.0/24",
|
|
62
|
+
// TEST-NET-2
|
|
63
|
+
"203.0.113.0/24",
|
|
64
|
+
// TEST-NET-3
|
|
65
|
+
"224.0.0.0/4",
|
|
66
|
+
// multicast
|
|
67
|
+
"240.0.0.0/4"
|
|
68
|
+
// reserved + broadcast
|
|
69
|
+
],
|
|
70
|
+
denyCidrsV6: [
|
|
71
|
+
"::/96",
|
|
72
|
+
// unspecified, ::1, IPv4-compatible
|
|
73
|
+
"64:ff9b::/96",
|
|
74
|
+
// NAT64 well-known
|
|
75
|
+
"64:ff9b:1::/48",
|
|
76
|
+
// NAT64 local-use
|
|
77
|
+
"100::/64",
|
|
78
|
+
// discard-only
|
|
79
|
+
"2001::/32",
|
|
80
|
+
// Teredo
|
|
81
|
+
"2001:10::/28",
|
|
82
|
+
// ORCHID
|
|
83
|
+
"2001:20::/28",
|
|
84
|
+
// ORCHIDv2
|
|
85
|
+
"2001:db8::/32",
|
|
86
|
+
// documentation
|
|
87
|
+
"2002::/16",
|
|
88
|
+
// 6to4
|
|
89
|
+
"fc00::/7",
|
|
90
|
+
// unique-local (IPv6 k8s service CIDRs)
|
|
91
|
+
"fe80::/10",
|
|
92
|
+
// link-local
|
|
93
|
+
"ff00::/8"
|
|
94
|
+
// multicast
|
|
95
|
+
],
|
|
96
|
+
denyHostSuffixes: [
|
|
97
|
+
".local",
|
|
98
|
+
".localhost",
|
|
99
|
+
".localdomain",
|
|
100
|
+
".internal",
|
|
101
|
+
".svc",
|
|
102
|
+
".cluster",
|
|
103
|
+
".arpa",
|
|
104
|
+
".lan",
|
|
105
|
+
".intranet",
|
|
106
|
+
".corp",
|
|
107
|
+
".home",
|
|
108
|
+
".test",
|
|
109
|
+
".invalid",
|
|
110
|
+
".example"
|
|
111
|
+
]
|
|
112
|
+
};
|
|
113
|
+
var privateNets = new import_node_net.BlockList();
|
|
114
|
+
function addDenyCidrs(cidrs, family) {
|
|
115
|
+
for (const cidr of cidrs) {
|
|
116
|
+
const slash = cidr.lastIndexOf("/");
|
|
117
|
+
privateNets.addSubnet(cidr.slice(0, slash), Number(cidr.slice(slash + 1)), family);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
addDenyCidrs(URL_VERIFY.denyCidrsV4, "ipv4");
|
|
121
|
+
addDenyCidrs(URL_VERIFY.denyCidrsV6, "ipv6");
|
|
122
|
+
var MAX_REDIRECTS = 20;
|
|
123
|
+
var REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
|
|
124
|
+
var CROSS_ORIGIN_STRIPPED_HEADERS = ["authorization", "proxy-authorization", "cookie", "host"];
|
|
125
|
+
function configureOutboundUrlGuard(config) {
|
|
126
|
+
guardEnabled = config.enabled ?? true;
|
|
127
|
+
allowedHosts = config.allowedHosts.map(normalizeHost);
|
|
128
|
+
blockedHosts = config.blockedHosts.map(normalizeHost);
|
|
129
|
+
}
|
|
130
|
+
function normalizeHost(hostname) {
|
|
131
|
+
const host = hostname.replace(/\.$/, "").toLowerCase();
|
|
132
|
+
return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
133
|
+
}
|
|
134
|
+
function isPrivateIp(address) {
|
|
135
|
+
const ip = address.replace(/^::ffff:/i, "");
|
|
136
|
+
if ((0, import_node_net.isIP)(ip) === 4) {
|
|
137
|
+
return privateNets.check(ip, "ipv4");
|
|
138
|
+
}
|
|
139
|
+
if ((0, import_node_net.isIP)(ip) === 6) {
|
|
140
|
+
return privateNets.check(ip, "ipv6");
|
|
141
|
+
}
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
function blockedError(host, cause) {
|
|
145
|
+
return new Error(`Outbound URL blocked for host "${host}"`, { cause });
|
|
146
|
+
}
|
|
147
|
+
function deny(host, cause) {
|
|
148
|
+
throw blockedError(host, cause);
|
|
149
|
+
}
|
|
150
|
+
function assertHost(host) {
|
|
151
|
+
if (host === "" || blockedHosts.includes(host)) {
|
|
152
|
+
deny(host);
|
|
153
|
+
}
|
|
154
|
+
if (allowedHosts.includes(host)) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if ((0, import_node_net.isIP)(host) === 0) {
|
|
158
|
+
if (!host.includes(".") || URL_VERIFY.denyHostSuffixes.some((suffix) => host.endsWith(suffix))) {
|
|
159
|
+
deny(host);
|
|
160
|
+
}
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (isPrivateIp(host)) {
|
|
164
|
+
deny(host);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function parseOutboundUrl(input) {
|
|
168
|
+
let url;
|
|
169
|
+
try {
|
|
170
|
+
url = new URL(input instanceof Request ? input.url : input);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
throw new Error("Outbound URL blocked", { cause: error });
|
|
173
|
+
}
|
|
174
|
+
if (!URL_VERIFY.allowedProtocols.includes(url.protocol)) {
|
|
175
|
+
throw new Error("Outbound URL blocked: only http and https are allowed");
|
|
176
|
+
}
|
|
177
|
+
return url;
|
|
178
|
+
}
|
|
179
|
+
var guardedLookup = (hostname, options, callback) => {
|
|
180
|
+
if (!guardEnabled) {
|
|
181
|
+
(0, import_node_dns.lookup)(hostname, options, callback);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const host = normalizeHost(hostname);
|
|
185
|
+
try {
|
|
186
|
+
assertHost(host);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
callback(error instanceof Error ? error : blockedError(host, error), "");
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (allowedHosts.includes(host) || (0, import_node_net.isIP)(host) !== 0) {
|
|
192
|
+
(0, import_node_dns.lookup)(hostname, options, callback);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const allOptions = { ...options, all: true };
|
|
196
|
+
(0, import_node_dns.lookup)(hostname, allOptions, (err, addresses) => {
|
|
197
|
+
if (err) {
|
|
198
|
+
callback(err, "");
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const first = addresses[0];
|
|
202
|
+
if (first === void 0 || addresses.some((record) => isPrivateIp(record.address))) {
|
|
203
|
+
callback(blockedError(host), "");
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (options.all) {
|
|
207
|
+
callback(null, addresses);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
callback(null, first.address, first.family);
|
|
211
|
+
});
|
|
212
|
+
};
|
|
213
|
+
var MCP_BODY_TIMEOUT_MS = 30 * 60 * 1e3;
|
|
214
|
+
var outboundAgent = new import_undici.Agent({
|
|
215
|
+
connect: { lookup: guardedLookup }
|
|
216
|
+
});
|
|
217
|
+
var mcpOutboundAgent = new import_undici.Agent({
|
|
218
|
+
// MCP SSE/streamable-HTTP stays idle between tool calls; undici's 300s bodyTimeout kills it.
|
|
219
|
+
bodyTimeout: MCP_BODY_TIMEOUT_MS,
|
|
220
|
+
connect: { lookup: guardedLookup }
|
|
221
|
+
});
|
|
222
|
+
async function assertSafeOutboundUrl(input) {
|
|
223
|
+
const url = parseOutboundUrl(input);
|
|
224
|
+
if (!guardEnabled) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const host = normalizeHost(url.hostname);
|
|
228
|
+
assertHost(host);
|
|
229
|
+
if (allowedHosts.includes(host) || (0, import_node_net.isIP)(host) !== 0) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
let addresses;
|
|
233
|
+
try {
|
|
234
|
+
addresses = (await (0, import_promises.lookup)(host, { all: true })).map((record) => record.address);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
deny(host, error);
|
|
237
|
+
}
|
|
238
|
+
if (addresses.some(isPrivateIp)) {
|
|
239
|
+
deny(host);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function nextHop(response, location, current, init) {
|
|
243
|
+
let nextUrl;
|
|
244
|
+
try {
|
|
245
|
+
nextUrl = new URL(location, current);
|
|
246
|
+
} catch (error) {
|
|
247
|
+
throw new Error("Outbound URL blocked", { cause: error });
|
|
248
|
+
}
|
|
249
|
+
if (!URL_VERIFY.allowedProtocols.includes(nextUrl.protocol)) {
|
|
250
|
+
throw new Error("Outbound URL blocked: only http and https are allowed");
|
|
251
|
+
}
|
|
252
|
+
const headers = new Headers(init.headers);
|
|
253
|
+
let method = (init.method ?? "GET").toUpperCase();
|
|
254
|
+
let body = init.body ?? null;
|
|
255
|
+
const downgradesToGet = (response.status === 301 || response.status === 302) && method === "POST" || response.status === 303 && method !== "GET" && method !== "HEAD";
|
|
256
|
+
if (downgradesToGet) {
|
|
257
|
+
method = "GET";
|
|
258
|
+
body = null;
|
|
259
|
+
headers.delete("content-encoding");
|
|
260
|
+
headers.delete("content-language");
|
|
261
|
+
headers.delete("content-location");
|
|
262
|
+
headers.delete("content-type");
|
|
263
|
+
headers.delete("content-length");
|
|
264
|
+
}
|
|
265
|
+
if (nextUrl.origin !== current.origin) {
|
|
266
|
+
for (const header of CROSS_ORIGIN_STRIPPED_HEADERS) {
|
|
267
|
+
headers.delete(header);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return { url: nextUrl, init: { ...init, method, headers, body } };
|
|
271
|
+
}
|
|
272
|
+
function mergeRequestInit(input, init) {
|
|
273
|
+
if (!(input instanceof Request)) {
|
|
274
|
+
return init;
|
|
275
|
+
}
|
|
276
|
+
return {
|
|
277
|
+
method: input.method,
|
|
278
|
+
headers: input.headers,
|
|
279
|
+
body: input.body,
|
|
280
|
+
signal: input.signal,
|
|
281
|
+
redirect: input.redirect,
|
|
282
|
+
...init
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
async function guardedFetch(input, init, hopsLeft, agent) {
|
|
286
|
+
const url = parseOutboundUrl(input);
|
|
287
|
+
if (guardEnabled) {
|
|
288
|
+
assertHost(normalizeHost(url.hostname));
|
|
289
|
+
}
|
|
290
|
+
const merged = mergeRequestInit(input, init);
|
|
291
|
+
const redirect = merged.redirect ?? "follow";
|
|
292
|
+
const followsRedirects = guardEnabled && redirect === "follow";
|
|
293
|
+
const requestInit = {
|
|
294
|
+
redirect: followsRedirects ? "manual" : redirect,
|
|
295
|
+
dispatcher: agent
|
|
296
|
+
};
|
|
297
|
+
Object.assign(requestInit, merged, {
|
|
298
|
+
redirect: followsRedirects ? "manual" : redirect,
|
|
299
|
+
dispatcher: agent,
|
|
300
|
+
...merged.body != null && typeof merged.body === "object" && "getReader" in merged.body ? { duplex: "half" } : {}
|
|
301
|
+
});
|
|
302
|
+
const response = await (0, import_undici.fetch)(url.href, requestInit);
|
|
303
|
+
if (!followsRedirects) {
|
|
304
|
+
return response;
|
|
305
|
+
}
|
|
306
|
+
if (!REDIRECT_STATUSES.has(response.status)) {
|
|
307
|
+
return response;
|
|
308
|
+
}
|
|
309
|
+
const location = response.headers.get("location");
|
|
310
|
+
if (location === null) {
|
|
311
|
+
return response;
|
|
312
|
+
}
|
|
313
|
+
void response.body?.cancel().catch(() => void 0);
|
|
314
|
+
if (hopsLeft === 0) {
|
|
315
|
+
throw new Error("Outbound URL blocked: too many redirects");
|
|
316
|
+
}
|
|
317
|
+
const hop = nextHop(response, location, url, merged);
|
|
318
|
+
return guardedFetch(hop.url, hop.init, hopsLeft - 1, agent);
|
|
319
|
+
}
|
|
320
|
+
async function ssrfFetch(input, init) {
|
|
321
|
+
return guardedFetch(input, init ?? {}, MAX_REDIRECTS, outboundAgent);
|
|
322
|
+
}
|
|
323
|
+
async function mcpSsrfFetch(input, init) {
|
|
324
|
+
return guardedFetch(input, init ?? {}, MAX_REDIRECTS, mcpOutboundAgent);
|
|
325
|
+
}
|
|
326
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
327
|
+
0 && (module.exports = {
|
|
328
|
+
assertSafeOutboundUrl,
|
|
329
|
+
configureOutboundUrlGuard,
|
|
330
|
+
mcpSsrfFetch,
|
|
331
|
+
ssrfFetch
|
|
332
|
+
});
|
|
333
|
+
//# sourceMappingURL=ssrfGuard.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/core/util/ssrfGuard.ts"],"sourcesContent":["import type { LookupAllOptions, LookupOptions } from 'node:dns';\nimport { lookup as dnsLookup } from 'node:dns';\nimport { lookup as dnsLookupAsync } from 'node:dns/promises';\nimport { BlockList, isIP, type LookupFunction } from 'node:net';\nimport { Agent, fetch as undiciFetch } from 'undici';\n\nlet allowedHosts: string[] = [];\nlet blockedHosts: string[] = [];\nlet guardEnabled = true;\n\nconst URL_VERIFY = {\n allowedProtocols: ['http:', 'https:'],\n denyCidrsV4: [\n '0.0.0.0/8', // this host\n '10.0.0.0/8', // private\n '100.64.0.0/10', // CGNAT (EKS secondary pod CIDRs)\n '127.0.0.0/8', // loopback\n '169.254.0.0/16', // link-local + metadata\n '172.16.0.0/12', // private (docker, k8s service CIDRs)\n '192.0.0.0/24', // IETF protocol assignments\n '192.0.2.0/24', // TEST-NET-1\n '192.88.99.0/24', // 6to4 relay anycast\n '192.168.0.0/16', // private\n '198.18.0.0/15', // benchmarking\n '198.51.100.0/24', // TEST-NET-2\n '203.0.113.0/24', // TEST-NET-3\n '224.0.0.0/4', // multicast\n '240.0.0.0/4', // reserved + broadcast\n ],\n denyCidrsV6: [\n '::/96', // unspecified, ::1, IPv4-compatible\n '64:ff9b::/96', // NAT64 well-known\n '64:ff9b:1::/48', // NAT64 local-use\n '100::/64', // discard-only\n '2001::/32', // Teredo\n '2001:10::/28', // ORCHID\n '2001:20::/28', // ORCHIDv2\n '2001:db8::/32', // documentation\n '2002::/16', // 6to4\n 'fc00::/7', // unique-local (IPv6 k8s service CIDRs)\n 'fe80::/10', // link-local\n 'ff00::/8', // multicast\n ],\n denyHostSuffixes: [\n '.local',\n '.localhost',\n '.localdomain',\n '.internal',\n '.svc',\n '.cluster',\n '.arpa',\n '.lan',\n '.intranet',\n '.corp',\n '.home',\n '.test',\n '.invalid',\n '.example',\n ],\n};\n\nconst privateNets = new BlockList();\nfunction addDenyCidrs(cidrs: readonly string[], family: 'ipv4' | 'ipv6'): void {\n for (const cidr of cidrs) {\n const slash = cidr.lastIndexOf('/');\n privateNets.addSubnet(cidr.slice(0, slash), Number(cidr.slice(slash + 1)), family);\n }\n}\naddDenyCidrs(URL_VERIFY.denyCidrsV4, 'ipv4');\naddDenyCidrs(URL_VERIFY.denyCidrsV6, 'ipv6');\n\nconst MAX_REDIRECTS = 20;\nconst REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);\nconst CROSS_ORIGIN_STRIPPED_HEADERS = ['authorization', 'proxy-authorization', 'cookie', 'host'];\n\nexport function configureOutboundUrlGuard(config: {\n enabled?: boolean;\n allowedHosts: readonly string[];\n blockedHosts: readonly string[];\n}): void {\n guardEnabled = config.enabled ?? true;\n allowedHosts = config.allowedHosts.map(normalizeHost);\n blockedHosts = config.blockedHosts.map(normalizeHost);\n}\n\nfunction normalizeHost(hostname: string): string {\n const host = hostname.replace(/\\.$/, '').toLowerCase();\n return host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;\n}\n\nfunction isPrivateIp(address: string): boolean {\n const ip = address.replace(/^::ffff:/i, '');\n if (isIP(ip) === 4) {\n return privateNets.check(ip, 'ipv4');\n }\n if (isIP(ip) === 6) {\n return privateNets.check(ip, 'ipv6');\n }\n return true;\n}\n\nfunction blockedError(host: string, cause?: unknown): Error {\n return new Error(`Outbound URL blocked for host \"${host}\"`, { cause });\n}\n\nfunction deny(host: string, cause?: unknown): never {\n throw blockedError(host, cause);\n}\n\nfunction assertHost(host: string): void {\n if (host === '' || blockedHosts.includes(host)) {\n deny(host);\n }\n if (allowedHosts.includes(host)) {\n return;\n }\n if (isIP(host) === 0) {\n if (!host.includes('.') || URL_VERIFY.denyHostSuffixes.some(suffix => host.endsWith(suffix))) {\n deny(host);\n }\n return;\n }\n if (isPrivateIp(host)) {\n deny(host);\n }\n}\n\nfunction parseOutboundUrl(input: string | URL | Request): URL {\n let url: URL;\n try {\n url = new URL(input instanceof Request ? input.url : input);\n } catch (error) {\n throw new Error('Outbound URL blocked', { cause: error });\n }\n if (!URL_VERIFY.allowedProtocols.includes(url.protocol)) {\n throw new Error('Outbound URL blocked: only http and https are allowed');\n }\n return url;\n}\n\n/** undici runs this as the socket lookup, so the addresses we allow are the ones connected to. */\nconst guardedLookup: LookupFunction = (hostname, options: LookupOptions, callback) => {\n if (!guardEnabled) {\n dnsLookup(hostname, options, callback);\n return;\n }\n const host = normalizeHost(hostname);\n try {\n assertHost(host);\n } catch (error) {\n callback(error instanceof Error ? error : blockedError(host, error), '');\n return;\n }\n if (allowedHosts.includes(host) || isIP(host) !== 0) {\n dnsLookup(hostname, options, callback);\n return;\n }\n const allOptions: LookupAllOptions = { ...options, all: true };\n dnsLookup(hostname, allOptions, (err, addresses) => {\n if (err) {\n callback(err, '');\n return;\n }\n const first = addresses[0];\n if (first === undefined || addresses.some(record => isPrivateIp(record.address))) {\n callback(blockedError(host), '');\n return;\n }\n if (options.all) {\n callback(null, addresses);\n return;\n }\n callback(null, first.address, first.family);\n });\n};\n\nconst MCP_BODY_TIMEOUT_MS = 30 * 60 * 1000;\n\nconst outboundAgent = new Agent({\n connect: { lookup: guardedLookup },\n});\n\nconst mcpOutboundAgent = new Agent({\n // MCP SSE/streamable-HTTP stays idle between tool calls; undici's 300s bodyTimeout kills it.\n bodyTimeout: MCP_BODY_TIMEOUT_MS,\n connect: { lookup: guardedLookup },\n});\n\nexport async function assertSafeOutboundUrl(input: string | URL | Request): Promise<void> {\n const url = parseOutboundUrl(input);\n if (!guardEnabled) {\n return;\n }\n const host = normalizeHost(url.hostname);\n assertHost(host);\n if (allowedHosts.includes(host) || isIP(host) !== 0) {\n return;\n }\n let addresses: string[];\n try {\n addresses = (await dnsLookupAsync(host, { all: true })).map(record => record.address);\n } catch (error) {\n deny(host, error);\n }\n if (addresses.some(isPrivateIp)) {\n deny(host);\n }\n}\n\nfunction nextHop(\n response: Response,\n location: string,\n current: URL,\n init: RequestInit,\n): { url: URL; init: RequestInit } {\n let nextUrl: URL;\n try {\n nextUrl = new URL(location, current);\n } catch (error) {\n throw new Error('Outbound URL blocked', { cause: error });\n }\n if (!URL_VERIFY.allowedProtocols.includes(nextUrl.protocol)) {\n throw new Error('Outbound URL blocked: only http and https are allowed');\n }\n\n const headers = new Headers(init.headers);\n let method = (init.method ?? 'GET').toUpperCase();\n let body = init.body ?? null;\n const downgradesToGet =\n ((response.status === 301 || response.status === 302) && method === 'POST') ||\n (response.status === 303 && method !== 'GET' && method !== 'HEAD');\n if (downgradesToGet) {\n method = 'GET';\n body = null;\n headers.delete('content-encoding');\n headers.delete('content-language');\n headers.delete('content-location');\n headers.delete('content-type');\n headers.delete('content-length');\n }\n if (nextUrl.origin !== current.origin) {\n for (const header of CROSS_ORIGIN_STRIPPED_HEADERS) {\n headers.delete(header);\n }\n }\n return { url: nextUrl, init: { ...init, method, headers, body } };\n}\n\nfunction mergeRequestInit(input: string | URL | Request, init: RequestInit): RequestInit {\n if (!(input instanceof Request)) {\n return init;\n }\n return {\n method: input.method,\n headers: input.headers,\n body: input.body,\n signal: input.signal,\n redirect: input.redirect,\n ...init,\n };\n}\n\nasync function guardedFetch(\n input: string | URL | Request,\n init: RequestInit,\n hopsLeft: number,\n agent: Agent,\n): Promise<Response> {\n const url = parseOutboundUrl(input);\n if (guardEnabled) {\n assertHost(normalizeHost(url.hostname));\n }\n const merged = mergeRequestInit(input, init);\n const redirect = merged.redirect ?? 'follow';\n const followsRedirects = guardEnabled && redirect === 'follow';\n const requestInit = {\n redirect: followsRedirects ? 'manual' : redirect,\n dispatcher: agent,\n };\n // npm undici vs @types/node undici-types: FormData/Headers do not line up under exactOptionalPropertyTypes.\n Object.assign(requestInit, merged, {\n redirect: followsRedirects ? 'manual' : redirect,\n dispatcher: agent,\n ...(merged.body != null && typeof merged.body === 'object' && 'getReader' in merged.body\n ? { duplex: 'half' as const }\n : {}),\n });\n const response = await undiciFetch(url.href, requestInit);\n if (!followsRedirects) {\n return response;\n }\n if (!REDIRECT_STATUSES.has(response.status)) {\n return response;\n }\n const location = response.headers.get('location');\n if (location === null) {\n return response;\n }\n void response.body?.cancel().catch(() => undefined);\n if (hopsLeft === 0) {\n throw new Error('Outbound URL blocked: too many redirects');\n }\n const hop = nextHop(response, location, url, merged);\n return guardedFetch(hop.url, hop.init, hopsLeft - 1, agent);\n}\n\nexport async function ssrfFetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {\n return guardedFetch(input, init ?? {}, MAX_REDIRECTS, outboundAgent);\n}\n\n/** Same as `ssrfFetch` with a 30m bodyTimeout for idle MCP SSE / streamable-HTTP. */\nexport async function mcpSsrfFetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {\n return guardedFetch(input, init ?? {}, MAX_REDIRECTS, mcpOutboundAgent);\n}\n"],"mappings":"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","names":["dnsLookup","dnsLookupAsync","undiciFetch"]}
|