@truefoundry/trueforge-core 0.1.2 → 0.1.3

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.
@@ -33,7 +33,7 @@ export interface SandboxOptions {
33
33
  /** Used to derive the Code Mode NATS wait as request + connect. */
34
34
  mcpRequestTimeoutMs: number;
35
35
  mcpConnectTimeoutMs: number;
36
- execExtraEnv?: Readonly<Record<string, string>> | undefined;
36
+ tenantName: string;
37
37
  tracing: AgentTracing;
38
38
  logger: Logger;
39
39
  }
@@ -55,7 +55,6 @@ export declare class Sandbox extends LocalToolMCP {
55
55
  private codeExecToolSets;
56
56
  private readonly fileDownloadEnabled;
57
57
  private readonly requestTimeoutSeconds;
58
- private readonly execExtraEnv?;
59
58
  private readonly skillMounter?;
60
59
  private cachedSkillsSection?;
61
60
  private readonly mcpClientScriptBase64;
@@ -1 +1 @@
1
- {"version":3,"file":"Sandbox.d.ts","sourceRoot":"","sources":["../../../src/core/sandbox/Sandbox.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"Sandbox.d.ts","sourceRoot":"","sources":["../../../src/core/sandbox/Sandbox.ts"],"names":[],"mappings":"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"}
@@ -104,7 +104,6 @@ function injectMCPClientEnv(params) {
104
104
  const codeModeEnv = params.codeModeEnv ?? {};
105
105
  const hasCodeModeTransport = Object.keys(codeModeEnv).length > 0;
106
106
  return {
107
- ...params.execExtraEnv ?? {},
108
107
  ...params.env ?? {},
109
108
  PYTHONPATH: MCP_CLIENT_DIR,
110
109
  ...Object.keys(params.mcpServers).length > 0 && {
@@ -153,7 +152,6 @@ var Sandbox = class _Sandbox extends import_LocalToolMCP.LocalToolMCP {
153
152
  codeExecToolSets = [];
154
153
  fileDownloadEnabled;
155
154
  requestTimeoutSeconds;
156
- execExtraEnv;
157
155
  skillMounter;
158
156
  cachedSkillsSection;
159
157
  mcpClientScriptBase64;
@@ -174,12 +172,11 @@ var Sandbox = class _Sandbox extends import_LocalToolMCP.LocalToolMCP {
174
172
  super({ tracing: options.tracing });
175
173
  this.provider = options.provider;
176
174
  this.existingSandboxId = options.existingSandboxId;
177
- this.tenantName = options.execExtraEnv?.["TFY_TENANT_NAME"] ?? "";
175
+ this.tenantName = options.tenantName;
178
176
  this.skillMounter = options.skillMounter;
179
177
  this.fileDownloadEnabled = options.fileDownloadEnabled ?? false;
180
178
  const mcpBoundTimeoutMs = options.mcpRequestTimeoutMs + options.mcpConnectTimeoutMs;
181
179
  this.requestTimeoutSeconds = Math.ceil(mcpBoundTimeoutMs / 1e3) + NATS_REQUEST_TIMEOUT_BUFFER_SECONDS;
182
- this.execExtraEnv = options.execExtraEnv;
183
180
  this.mcpClientScriptBase64 = Buffer.from(import_sandboxScripts.sandboxScripts.mcpClient, "utf-8").toString("base64");
184
181
  this.logger = options.logger.child({ module: "Sandbox" });
185
182
  this.resolvedGitCredentialsContent = options.resolvedGitCredentialsContent ?? null;
@@ -398,7 +395,6 @@ var Sandbox = class _Sandbox extends import_LocalToolMCP.LocalToolMCP {
398
395
  const mcpClientEnv = injectMCPClientEnv({
399
396
  env: input.env,
400
397
  mcpServers: this.buildMcpServersEnvelope(),
401
- execExtraEnv: this.execExtraEnv,
402
398
  codeModeEnv
403
399
  });
404
400
  const gitAuthEnv = this.resolvedGitCredentialsContent !== null ? buildGitCredentialHelperEnv(this.provider.getGitCredentialsPath(sandboxInfo.sandbox_id)) : {};
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/core/sandbox/Sandbox.ts"],"sourcesContent":["import { context, propagation } from '@opentelemetry/api';\nimport dedent from 'dedent';\nimport type { Logger } from 'winston';\nimport { z } from 'zod';\nimport { InstructionBuilder } from '../InstructionBuilder';\nimport { estimateTokensForString } from '../llm/usage';\nimport type { MappedMCPTool } from '../mcp/convertMCPServers';\nimport { toolResultResponse, type CallToolResponse, type IToolSet } from '../mcp/IMCPServer';\nimport { defineTool, LocalToolMCP } from '../mcp/LocalToolMCP';\nimport {\n DEFERRED_TOOLS_SERVER_ID,\n GET_TOOL_INFO_NAME,\n GET_TOOL_OUTPUT_SCHEMA_NAME,\n LIST_TOOLS_NAME,\n} from '../runtime/DeferredTool';\nimport type { AgentTracing } from '../tracing/AgentTracing';\nimport { extractErrorLogFields } from '../util/errorLogFields';\nimport { CodeModeDispatcher } from './codeMode/CodeModeDispatcher';\nimport type { CodeModeTransport } from './codeMode/CodeModeTransport';\nimport { SANDBOX_FILE_UPLOADS_DIR } from './constants';\nimport { ensureExecSuccess, shellEscape, type SandboxProvider } from './provider/Provider';\nimport { validateNoPathTraversal, validateSandboxOwnedByTenant } from './SandboxErrors';\nimport { sandboxScripts } from './sandboxScripts.gen';\n// Import submodules, not the ./skills barrel, to avoid a cycle (the mounters import from Sandbox).\nimport { SKILLS_DIR } from './skills/constants';\nimport type { ISkillMounter } from './skills/ISkillMounter';\n\nexport interface SandboxInfo {\n sandbox_id: string;\n}\n\n// Downloader/setup scripts can run longer than a normal exec.\nexport const SKILL_DOWNLOAD_TIMEOUT_SECONDS = 180;\n\n// Keeps the sandbox-side NATS wait longer than the host's own MCP deadline, so the host\n// surfaces the real MCP error instead of the bridge client giving up first.\nconst NATS_REQUEST_TIMEOUT_BUFFER_SECONDS = 30;\n\n// Write a script (base64-encoded, never interpolated raw) to a path and run it. Skill mounters reuse it.\nexport function buildWriteAndRunScriptCommand(params: { scriptPath: string; scriptContent: string }): string {\n const b64 = Buffer.from(params.scriptContent, 'utf-8').toString('base64');\n return [\n `mkdir -p \"$(dirname ${params.scriptPath})\"`,\n `rm -f ${params.scriptPath}`,\n `echo ${shellEscape(b64)} | base64 -d > ${params.scriptPath}`,\n `chmod a-w ${params.scriptPath}`,\n `python3 ${params.scriptPath}`,\n ].join(' && ');\n}\n\n/** Write or clear the git credential-store file at an absolute path (no global git config mutation). */\nfunction buildSyncGitCredentialsCommand(credentialsContent: string | null, credentialsPath: string): string {\n if (credentialsContent === null) {\n return `rm -f ${credentialsPath}`;\n }\n // Base64 avoids interpolating credentials directly into the shell command.\n const b64 = Buffer.from(credentialsContent, 'utf-8').toString('base64');\n return (\n `mkdir -p \"$(dirname ${credentialsPath})\" && ` +\n `echo '${b64}' | base64 -d > ${credentialsPath} && ` +\n `chmod 600 ${credentialsPath}`\n );\n}\n\n/**\n * Process-scoped credential.helper via GIT_CONFIG_* (avoids shared ~/.gitconfig).\n * Required for TFY sandbox where many logical sandboxes share one pod/HOME.\n */\nfunction buildGitCredentialHelperEnv(credentialsPath: string): Record<string, string> {\n return {\n GIT_CONFIG_COUNT: '1',\n GIT_CONFIG_KEY_0: 'credential.helper',\n GIT_CONFIG_VALUE_0: `store --file ${credentialsPath}`,\n };\n}\n\nexport interface SandboxStoredFile {\n filePath: string;\n sandboxCreated?: SandboxInfo | undefined;\n}\n\nexport interface SandboxOptions {\n provider: SandboxProvider;\n existingSandboxId?: string | undefined;\n skillMounter?: ISkillMounter | undefined;\n fileDownloadEnabled?: boolean | undefined;\n /** Pre-resolved credential-store file content (null = clear / no git auth). */\n resolvedGitCredentialsContent?: string | null | undefined;\n /**\n * Blocks destructive tools in code mode so they go through the approval flow\n * instead. Must be `true` — approvals are always enabled (the kill switch is gone).\n */\n blockDestructiveToolsInCodeMode: true;\n /** Used to derive the Code Mode NATS wait as request + connect. */\n mcpRequestTimeoutMs: number;\n mcpConnectTimeoutMs: number;\n execExtraEnv?: Readonly<Record<string, string>> | undefined;\n tracing: AgentTracing;\n logger: Logger;\n}\n\nexport const SANDBOX_EXEC_TOOL_NAME = 'exec';\nconst SANDBOX_MCP_REMINDER_TAG = 'sandbox-mcp-code-mode';\nconst SANDBOX_FILE_OUTPUT_TAG = 'sandbox-file-output';\nexport const SANDBOX_SCHEMA_INFER_TAG = 'sandbox-schema-infer';\nconst SANDBOX_FILE_UPLOADS_TAG = 'sandbox-file-uploads';\n\n// Stable directory inside the sandbox where the MCP client library is uploaded.\n// MCP client upload dir. Two lookup channels:\n// - Python imports → PYTHONPATH points here (set by injectMCPClientEnv).\n// - Shell `mcp-client` → symlinked into MCP_CLIENT_BIN_SYMLINK (on default PATH);\n// basename drops `.py` so the agent doesn't reflexively prefix with `python`.\nconst MCP_CLIENT_DIR = '/opt/tfy/mcp-client';\nconst MCP_CLIENT_PATH = `${MCP_CLIENT_DIR}/mcp_client.py`;\nconst MCP_CLIENT_BIN_SYMLINK = '/usr/local/bin/mcp-client';\n\n// Per-server config sent to mcp_client.py via TFY_MCP_SERVERS.\ninterface SandboxMcpServerConfig {\n allowed_tools: string[];\n}\n\n// Snapshots the active W3C trace context (`traceparent` + optional `tracestate`) into env\n// vars consumed by mcp_client.py. Read with TFY_TRACEPARENT / TFY_TRACESTATE on the sandbox\n// side and attached as NATS message headers; the bridge extracts them on each message so the\n// resulting MCP spans nest under the originating Sandbox: exec span.\nfunction injectTraceContextEnv(): Record<string, string> {\n const carrier: Record<string, string> = {};\n propagation.inject(context.active(), carrier);\n const env: Record<string, string> = {};\n if (carrier['traceparent']) {\n env['TFY_TRACEPARENT'] = carrier['traceparent'];\n }\n if (carrier['tracestate']) {\n env['TFY_TRACESTATE'] = carrier['tracestate'];\n }\n return env;\n}\n\n/**\n * Injects environment variables required for mcp_client.py into sandbox_exec arguments.\n * Transport-specific vars (NATS URL/prefix/timeout) come from `codeModeEnv` via transport.start().\n */\nfunction injectMCPClientEnv(params: {\n env?: Record<string, string> | undefined;\n mcpServers: Record<string, SandboxMcpServerConfig>;\n execExtraEnv?: Readonly<Record<string, string>> | undefined;\n codeModeEnv?: Record<string, string> | undefined;\n}): Record<string, string> {\n const codeModeEnv = params.codeModeEnv ?? {};\n const hasCodeModeTransport = Object.keys(codeModeEnv).length > 0;\n return {\n ...(params.execExtraEnv ?? {}),\n ...(params.env ?? {}),\n PYTHONPATH: MCP_CLIENT_DIR,\n ...(Object.keys(params.mcpServers).length > 0 && {\n TFY_MCP_SERVERS: Buffer.from(JSON.stringify(params.mcpServers)).toString('base64'),\n }),\n ...codeModeEnv,\n ...(hasCodeModeTransport && {\n // W3C trace context captured per-exec so each Code Mode request carries the originating\n // Sandbox: exec span as parent.\n ...injectTraceContextEnv(),\n }),\n // Approvals are always enabled: destructive tools cannot run in code mode.\n TFY_ENABLE_AGENT_APPROVALS: 'true',\n };\n}\n\nconst sandboxExecSchema = z.object({\n intent: z\n .string()\n .describe(\n 'A brief, user-facing explanation of the purpose of this command. Avoid technical jargon and code details.',\n ),\n command: z.string().describe('The shell command to execute in the sandbox.'),\n cwd: z.string().optional().describe('Working directory for command execution.'),\n env: z.record(z.string(), z.string()).optional().describe('Additional environment variables to set.'),\n});\n\nconst SANDBOX_EXEC_DESCRIPTION = `Execute shell commands in a persistent sandbox environment.\nThe sandbox persists across calls within the same session, allowing stateful operations.\nPre-installed: Python 3.13, pydantic, git, curl, helm, jq, ripgrep, genson.\nIMPORTANT: The Agent must never list, print, or expose environment variables as they contain sensitive credentials.\nIMPORTANT: Never read or expose any git credential files or ~/.git-credentials.\n`;\n\nexport function createSandboxLargeToolResponseGuidance(): string {\n return `For use cases where the Agent needs a subset of fields from the MCP response or needs deterministic processing, write code with the provided MCP client and use <${SANDBOX_MCP_REMINDER_TAG}>.`;\n}\n\nexport const SANDBOX_MCP_SERVER_ID = 'sandbox';\ntype SandboxExecInput = z.infer<typeof sandboxExecSchema>;\n\nexport class Sandbox extends LocalToolMCP {\n readonly name = SANDBOX_MCP_SERVER_ID;\n readonly displayName = 'Sandbox';\n override readonly description = 'Persistent sandbox environment for code execution';\n static readonly FILE_UPLOADS_DIR = SANDBOX_FILE_UPLOADS_DIR;\n\n private readonly provider: SandboxProvider;\n private readonly existingSandboxId?: string | undefined;\n private readonly tenantName: string;\n private existingSandboxInfo: SandboxInfo | undefined;\n // Cached promise to prevent concurrent sub-agents from creating duplicate sandboxes.\n private sandboxCreationPromise?: Promise<SandboxInfo> | undefined;\n private sandboxInitPromise?: Promise<void> | undefined;\n private codeExecToolSets: readonly IToolSet[] = [];\n private readonly fileDownloadEnabled: boolean;\n private readonly requestTimeoutSeconds: number;\n private readonly execExtraEnv?: Readonly<Record<string, string>> | undefined;\n private readonly skillMounter?: ISkillMounter | undefined;\n private cachedSkillsSection?: string | undefined;\n private readonly mcpClientScriptBase64: string;\n private readonly logger: Logger;\n // Pre-resolved credential-store file content (null = clear / no git auth).\n private readonly resolvedGitCredentialsContent: string | null;\n private codeModeDispatcher: CodeModeDispatcher | undefined;\n private codeModeTransport: CodeModeTransport | undefined;\n\n private tools = [\n defineTool({\n name: SANDBOX_EXEC_TOOL_NAME,\n description: SANDBOX_EXEC_DESCRIPTION,\n schema: sandboxExecSchema,\n handler: input => this.handleExec(input),\n }),\n ];\n\n constructor(options: SandboxOptions) {\n super({ tracing: options.tracing });\n this.provider = options.provider;\n this.existingSandboxId = options.existingSandboxId;\n this.tenantName = options.execExtraEnv?.['TFY_TENANT_NAME'] ?? '';\n this.skillMounter = options.skillMounter;\n this.fileDownloadEnabled = options.fileDownloadEnabled ?? false;\n const mcpBoundTimeoutMs = options.mcpRequestTimeoutMs + options.mcpConnectTimeoutMs;\n this.requestTimeoutSeconds = Math.ceil(mcpBoundTimeoutMs / 1000) + NATS_REQUEST_TIMEOUT_BUFFER_SECONDS;\n this.execExtraEnv = options.execExtraEnv;\n // Scripts are internal to Sandbox: the upload paths, env contract, and prompt\n // text are all hardcoded here, so injecting different content was never a\n // real extension point. Consumers don't see or provide them.\n this.mcpClientScriptBase64 = Buffer.from(sandboxScripts.mcpClient, 'utf-8').toString('base64');\n this.logger = options.logger.child({ module: 'Sandbox' });\n this.resolvedGitCredentialsContent = options.resolvedGitCredentialsContent ?? null;\n\n if (this.existingSandboxId) {\n validateSandboxOwnedByTenant(this.existingSandboxId, this.tenantName);\n this.existingSandboxInfo = { sandbox_id: this.existingSandboxId };\n }\n }\n\n private buildMcpServersEnvelope(): Record<string, SandboxMcpServerConfig> {\n const out: Record<string, SandboxMcpServerConfig> = {};\n for (const server of this.codeExecToolSets) {\n const allowedTools = server.getAllowedToolNamesForSandbox?.() ?? [];\n out[server.name] = { allowed_tools: allowedTools };\n }\n return out;\n }\n\n protected getTools() {\n return this.tools;\n }\n\n configureCodeMode(servers: readonly IToolSet[]): void {\n if (!servers.length) {\n return;\n }\n if (this.codeModeDispatcher !== undefined || this.codeModeTransport !== undefined) {\n throw new Error('Code Mode is already configured for this Sandbox');\n }\n this.codeExecToolSets = servers;\n this.codeModeDispatcher = new CodeModeDispatcher({ toolSets: servers, logger: this.logger });\n this.codeModeTransport = this.provider.createCodeModeTransport();\n }\n\n estimateSkillsTokens(): number {\n return Math.round(estimateTokensForString(this.renderSkillsSection()));\n }\n\n // Single source for the <skills> section, so the prompt and the token estimate can't drift.\n private renderSkillsSection(): string {\n if (this.cachedSkillsSection === undefined) {\n const skills = new InstructionBuilder('skills');\n this.skillMounter?.instruction(skills);\n this.cachedSkillsSection = skills.build();\n }\n return this.cachedSkillsSection;\n }\n\n buildInstruction(builder: InstructionBuilder): void {\n const providerInstructions = this.provider.getAdditionalInstructions();\n\n const sandboxInstructions = builder.beginSection('sandbox');\n sandboxInstructions.addContent('The Agent has access to a persistent sandbox environment for executing code.');\n sandboxInstructions.addContent('The Agent must NOT read or modify any git credential files.');\n\n this.buildSchemaSection(sandboxInstructions);\n this.buildSkillsSection(sandboxInstructions);\n this.buildMCPSection(sandboxInstructions);\n this.buildFileUploadsSection(sandboxInstructions);\n\n if (this.fileDownloadEnabled) {\n this.buildFileOutputSection(sandboxInstructions);\n }\n\n if (providerInstructions) {\n sandboxInstructions.addContent(providerInstructions);\n }\n }\n\n private buildFileUploadsSection(builder: InstructionBuilder): void {\n builder.addSection(\n SANDBOX_FILE_UPLOADS_TAG,\n `User-uploaded files are placed in ${Sandbox.FILE_UPLOADS_DIR}/. The Agent must not modify files in this directory. Always create a copy at a different location and work on the copy.`,\n );\n }\n\n private buildSchemaSection(builder: InstructionBuilder): void {\n builder.addSection(\n SANDBOX_SCHEMA_INFER_TAG,\n dedent`\n To infer the schema of a file, the Agent MUST execute:\n head -c 200 file_path && echo \"==\" && genson file_path\n This serves two purpose:\n 1. The first 200 characters of the file is printed. Do NOT print more than 200 characters.\n 2. genson infers and returns JSON schema if the file is JSON.\n Even if the file is not JSON, (1) helps to take the next step in less number of turns\n `,\n );\n }\n\n private buildFileOutputSection(builder: InstructionBuilder): void {\n builder.addSection(\n SANDBOX_FILE_OUTPUT_TAG,\n dedent`\n ## File outputs\n\n The user does not have direct access to the sandbox filesystem. When the Agent wants to output a file to the user, it MUST:\n 1. Ensure that the file is present in the sandbox.\n 2. Then emit a fenced sandbox_artifacts block referencing the file.\n\n \\`\\`\\`sandbox_artifacts\n [Human-readable label](/absolute/path/to/file.ext)\n \\`\\`\\`\n\n For large outputs or content that can be generated through code, prefer writing to a file and emitting the sandbox_artifacts block.\n\n Rules:\n - Use absolute paths, not ~/ or relative paths.\n - One link per line. Markdown link syntax: [label](path).`,\n );\n }\n\n private buildSkillsSection(builder: InstructionBuilder): void {\n builder.addContent(this.renderSkillsSection());\n }\n\n private buildMCPSection(builder: InstructionBuilder): void {\n const toolSetNames = this.codeExecToolSets.map(m => m.name).join(',');\n if (!toolSetNames) {\n return;\n }\n\n builder.addSection(\n SANDBOX_MCP_REMINDER_TAG,\n dedent`\n The following MCP servers are accessible for Code Mode from ${SANDBOX_EXEC_TOOL_NAME} via the pre-installed mcp_client module: ${toolSetNames}\n Tool and functions are same.\n Tool and MCP tools are same.\n\n Use \\`from mcp_client import call_tool\\` in code, or \\`mcp-client\\` in shell — both work from any directory with no setup.\n\n For deferred MCP servers, the Agent MUST:\n 1. Discover tools via ${LIST_TOOLS_NAME} from ${DEFERRED_TOOLS_SERVER_ID}. Never call ${GET_TOOL_OUTPUT_SCHEMA_NAME}.\n 2. Discover inputSchema, outputSchema via ${GET_TOOL_INFO_NAME} for the selected tools.\n\n For non-deferred MCP servers, the Agent MUST:\n 1. Discover outputSchema via ${GET_TOOL_OUTPUT_SCHEMA_NAME} for the selected tools.\n\n For both deferred and non-deferred if outputSchema is absent,\n the Agent MUST say: \"Given the MCP Server does not define an outputSchema let me figure it out first before entering Code Mode.\"\n mcp-client call-tool {server} {tool} '{\"param\":\"value\"}' > file && ${SANDBOX_SCHEMA_INFER_TAG}.\n the Agent MUST execute a single command for above.\n If the Agent does not get the outputSchema, the Agent will encounters scenarios like:\n Agent: \"Let me fix the code - it seems the x might be a string rather than an object in some cases:\"\n The Agent MUST not face the above scenario.\n \n Only after the Agent has the above details, it can enter Code Mode.\n Agent: Now I know the inputSchema and outputSchema of tool_a, let me enter Code Mode.\n\n CLI: mcp-client call-tool {server} {tool_a} '{\"param\":\"value\"}'\n # The output of the above command MUST be passed through tools like jq or head -c\n # or should be written to a file. Goal is to reduce content that the Agent prints.\n\n Code: from mcp_client import call_tool\n async def main():\n result = await call_tool(\"{server}\", \"{tool_a}\", body={\"param1\": \"value1\"})\n # In most cases, it is safe to assume response is a dict.\n # To know the dict keys, the Agent still needs to figure out the outputSchema.\n # The Agent MUST write the code to a file first before executing it.\n\n Code Mode MUST be used in following scenarios:\n\n 1. Reduce structured tool output by executing logic on top of it. Never present raw output.\n Agent: Now I know the inputSchema and outputSchema of tool_a, let me enter Code Mode.\n\n from mcp_client import call_tool\n from collections import Counter\n result = await call_tool(\"{server}\", \"{tool_a}\", body={\"param1\": \"value1\"})\n print(Counter(r[\"a\"] for r in result[\"b\"] if r[\"c\"])) # Here we need outputSchema to know a, b and c exists.\n\n 2. Piping result of one tool call to another tool request. Never present raw output.\n Agent: Now I know the inputSchema and outputSchema of tool1, let me enter Code Mode.\n\n from mcp_client import call_tool\n result1 = await call_tool(\"{server}\", \"{tool1}\", body={\"param1\": \"value1\"})\n result2 = await call_tool(\"{server}\", \"{tool2}\", body={\"param2\": result1[\"value1\"]}) # Here we need outputSchema to know value1 exists.\n print(result2) # Note in this case, we do not need outputSchema of result2 as we are not using any fields.\n\n The Agent MUST NOT use Code Mode just to print the full raw response of a tool. The Agent MUST call the tool directly in this case, there are other mechanisms in place to ensure\n large tool call outputs are not loaded in context.\n \n The Agent must not use Code Mode in the following patterns:\n from mcp_client import call_tool\n import json\n result = await call_tool(\"{server}\", \"{tool}\", body={\"param1\": \"value1\"})\n print(result) # Never do this. Call tool without Code Mode.\n print(json.dumps(result, ...)) # Never do this. Call tool without Code Mode.\n print(result[\"a\"][:100]) # Never do this. Call tool without Code Mode.\n\n mcp-client call-tool {server} {tool} '{\"param\":\"value\"}' > data && cat data # Never do this. Call tool without Code Mode.\n `,\n );\n }\n\n private get requiredSandboxInfo(): SandboxInfo {\n if (!this.existingSandboxInfo) {\n throw new Error('Sandbox info not available');\n }\n return this.existingSandboxInfo;\n }\n\n private async ensureSandboxCreated(): Promise<{\n sandboxInfo: SandboxInfo;\n sandboxCreated: SandboxInfo | undefined;\n }> {\n if (this.existingSandboxInfo) {\n return { sandboxInfo: this.existingSandboxInfo, sandboxCreated: undefined };\n }\n // Provider returns camelCase `{ sandboxId }`; rename to the snake_case\n // `SandboxInfo` shape used everywhere downstream (Redis JSON, wire event,\n // in-memory state).\n this.sandboxCreationPromise ??= this.provider\n .createSandbox()\n .then(({ sandboxId }) => {\n validateSandboxOwnedByTenant(sandboxId, this.tenantName);\n return { sandbox_id: sandboxId };\n })\n .catch((e: unknown) => {\n this.sandboxCreationPromise = undefined;\n throw e;\n });\n\n const sandboxInfo = await this.sandboxCreationPromise;\n // Concurrent callers may both await the same creation promise; only the first\n // should report sandboxCreated. Read via a local to avoid control-flow narrowing\n // that treats existingSandboxInfo as always undefined after the early return above.\n const prior = this.existingSandboxInfo as SandboxInfo | undefined;\n const isNew = prior === undefined;\n this.existingSandboxInfo = sandboxInfo;\n return { sandboxInfo, sandboxCreated: isNew ? sandboxInfo : undefined };\n }\n\n private async handleExec(input: SandboxExecInput): Promise<CallToolResponse> {\n const { sandboxInfo, sandboxCreated } = await this.ensureSandboxCreated();\n const sandboxCreatedFlag = Boolean(sandboxCreated);\n try {\n await this.ensureSandboxInitialized();\n } catch (e) {\n this.logger.error('Sandbox initialization failed', extractErrorLogFields(e));\n const message = e instanceof Error ? e.message : 'Sandbox initialization failed';\n return toolResultResponse({\n text: `Sandbox initialization failed: ${message}`,\n isError: true,\n overrides: { sandboxCreated: sandboxCreatedFlag, sandboxInfo },\n });\n }\n const codeModeEnv = await this.ensureCodeModeStarted(sandboxInfo.sandbox_id);\n const mcpClientEnv = injectMCPClientEnv({\n env: input.env,\n mcpServers: this.buildMcpServersEnvelope(),\n execExtraEnv: this.execExtraEnv,\n codeModeEnv,\n });\n const gitAuthEnv =\n this.resolvedGitCredentialsContent !== null\n ? buildGitCredentialHelperEnv(this.provider.getGitCredentialsPath(sandboxInfo.sandbox_id))\n : {};\n const env = { ...mcpClientEnv, ...gitAuthEnv };\n\n const result = await this.provider.exec({\n sandboxId: sandboxInfo.sandbox_id,\n command: input.command,\n cwd: input.cwd,\n env,\n });\n\n return {\n result: {\n content: [{ type: 'text', text: JSON.stringify(result) }],\n isError: !result.success,\n },\n wasInitialized: undefined,\n sandboxCreated: sandboxCreatedFlag,\n sandboxInfo,\n };\n }\n\n async getToolResultDumpDir(): Promise<{ dir: string; sandboxCreated: SandboxInfo | undefined }> {\n const { sandboxCreated } = await this.ensureSandboxCreated();\n const dir = this.provider.getToolResultDumpDir(this.requiredSandboxInfo.sandbox_id).replace(/\\/+$/, '');\n return { dir, sandboxCreated };\n }\n\n async uploadFile(params: {\n targetDir: string;\n fileName: string;\n content: Buffer;\n }): Promise<{ filePath: string; sandboxCreated: SandboxInfo | undefined }> {\n const { sandboxCreated } = await this.ensureSandboxCreated();\n await this.ensureSandboxInitialized();\n const { sandbox_id: sandboxId } = this.requiredSandboxInfo;\n const fileName = params.fileName.replace(/^\\/+/, '');\n const targetDir = params.targetDir.replace(/\\/+$/, '');\n const filePath = `${targetDir}/${fileName}`;\n await this.provider.uploadFile({ sandboxId, remotePath: filePath, content: params.content });\n return { filePath, sandboxCreated };\n }\n\n async uploadUserFile(input: { fileName: string; content: Buffer; mime: string }): Promise<SandboxStoredFile> {\n validateNoPathTraversal(input.fileName);\n const result = await this.uploadFile({\n targetDir: Sandbox.FILE_UPLOADS_DIR,\n fileName: input.fileName,\n content: input.content,\n });\n return {\n filePath: result.filePath,\n ...(result.sandboxCreated && { sandboxCreated: result.sandboxCreated }),\n };\n }\n\n async writeArtifact(input: {\n fileName: string;\n content: Buffer;\n sourceTool?: MappedMCPTool | undefined;\n }): Promise<SandboxStoredFile> {\n validateNoPathTraversal(input.fileName);\n const { dir, sandboxCreated: dumpDirCreated } = await this.getToolResultDumpDir();\n const result = await this.uploadFile({\n targetDir: dir,\n fileName: input.fileName,\n content: input.content,\n });\n const sandboxCreated = dumpDirCreated ?? result.sandboxCreated;\n return {\n filePath: result.filePath,\n ...(sandboxCreated && { sandboxCreated }),\n };\n }\n\n /**\n * Upload the MCP client Python script to the sandbox on the first tool call.\n * The sandbox filesystem is persistent within a session, so this only needs to run once.\n */\n private async ensureSandboxInitialized(): Promise<void> {\n this.sandboxInitPromise ??= this.initSandboxEnvironment().catch((e: unknown) => {\n this.sandboxInitPromise = undefined;\n throw e;\n });\n await this.sandboxInitPromise;\n }\n\n private async writeGitCredentials(): Promise<void> {\n const sandboxId = this.requiredSandboxInfo.sandbox_id;\n const credentialsPath = this.provider.getGitCredentialsPath(sandboxId);\n const result = await this.provider.exec({\n sandboxId,\n command: buildSyncGitCredentialsCommand(this.resolvedGitCredentialsContent, credentialsPath),\n });\n ensureExecSuccess(result);\n }\n\n private async initSandboxEnvironment(): Promise<void> {\n const toolResultDumpDir = this.provider.getToolResultDumpDir(this.requiredSandboxInfo.sandbox_id);\n\n this.logger.info('Uploading MCP client script and preparing skills directory in sandbox');\n\n const initSteps = [\n `mkdir -p ${MCP_CLIENT_DIR} ${Sandbox.FILE_UPLOADS_DIR} ${toolResultDumpDir} ${SKILLS_DIR}`,\n `rm -f ${MCP_CLIENT_PATH}`,\n `echo '${this.mcpClientScriptBase64}' | base64 -d > ${MCP_CLIENT_PATH}`,\n // Make executable + symlink onto PATH so `mcp-client` runs by name.\n `chmod 0555 ${MCP_CLIENT_PATH}`,\n `ln -sf ${MCP_CLIENT_PATH} ${MCP_CLIENT_BIN_SYMLINK}`,\n ];\n\n // Fold skill installation into this single init exec. The mounter returns a declarative\n // command + env + timeout; runs even when empty so its downloader can prune a reused sandbox.\n const skillInit = this.skillMounter?.getSandboxInit();\n if (skillInit) {\n initSteps.push(skillInit.command);\n }\n\n const script = initSteps.join(' && ');\n const result = await this.provider.exec({\n sandboxId: this.requiredSandboxInfo.sandbox_id,\n command: script,\n env: skillInit?.env,\n timeoutSeconds: skillInit?.timeoutSeconds,\n });\n ensureExecSuccess(result);\n this.logger.info(\n `Sandbox initialized: MCP client at ${MCP_CLIENT_PATH} (symlinked to ${MCP_CLIENT_BIN_SYMLINK}); skills dir ${SKILLS_DIR}`,\n );\n\n await this.writeGitCredentials();\n }\n\n /**\n * Lazily start Code Mode transport. Single-flight and idempotent inside the transport.\n * Skipped when code mode was never configured. Connect failures resolve to `undefined`\n * (no Code Mode env for that exec); mcp_client then errors at call time — no HTTPS fallback.\n */\n private async ensureCodeModeStarted(sandboxId: string): Promise<Record<string, string> | undefined> {\n const dispatcher = this.codeModeDispatcher;\n const transport = this.codeModeTransport;\n if (dispatcher === undefined || transport === undefined) {\n return undefined;\n }\n try {\n const { env } = await transport.start({\n codeModeDispatcher: dispatcher,\n sandboxId,\n requestTimeoutSeconds: this.requestTimeoutSeconds,\n });\n return env;\n } catch (e) {\n // Connect-level errors are not sticky: transport clears its session so a later exec may retry.\n this.logger.error('Code Mode transport start failed', {\n ...extractErrorLogFields(e),\n sandboxId,\n });\n return undefined;\n }\n }\n\n // Tears down per-Sandbox Code Mode resources. Dispatcher close first (in-flight requests get\n // source: transport), then transport.stop() — always safe whether start succeeded, failed, or never ran.\n async close(): Promise<void> {\n this.codeModeDispatcher?.close();\n this.codeModeDispatcher = undefined;\n const transport = this.codeModeTransport;\n this.codeModeTransport = undefined;\n if (transport === undefined) {\n return;\n }\n try {\n await transport.stop();\n } catch (e) {\n this.logger.warn('Error stopping Code Mode transport', extractErrorLogFields(e));\n }\n }\n}\n"],"mappings":"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","names":["import_constants","dedent"]}
1
+ {"version":3,"sources":["../../../src/core/sandbox/Sandbox.ts"],"sourcesContent":["import { context, propagation } from '@opentelemetry/api';\nimport dedent from 'dedent';\nimport type { Logger } from 'winston';\nimport { z } from 'zod';\nimport { InstructionBuilder } from '../InstructionBuilder';\nimport { estimateTokensForString } from '../llm/usage';\nimport type { MappedMCPTool } from '../mcp/convertMCPServers';\nimport { toolResultResponse, type CallToolResponse, type IToolSet } from '../mcp/IMCPServer';\nimport { defineTool, LocalToolMCP } from '../mcp/LocalToolMCP';\nimport {\n DEFERRED_TOOLS_SERVER_ID,\n GET_TOOL_INFO_NAME,\n GET_TOOL_OUTPUT_SCHEMA_NAME,\n LIST_TOOLS_NAME,\n} from '../runtime/DeferredTool';\nimport type { AgentTracing } from '../tracing/AgentTracing';\nimport { extractErrorLogFields } from '../util/errorLogFields';\nimport { CodeModeDispatcher } from './codeMode/CodeModeDispatcher';\nimport type { CodeModeTransport } from './codeMode/CodeModeTransport';\nimport { SANDBOX_FILE_UPLOADS_DIR } from './constants';\nimport { ensureExecSuccess, shellEscape, type SandboxProvider } from './provider/Provider';\nimport { validateNoPathTraversal, validateSandboxOwnedByTenant } from './SandboxErrors';\nimport { sandboxScripts } from './sandboxScripts.gen';\n// Import submodules, not the ./skills barrel, to avoid a cycle (the mounters import from Sandbox).\nimport { SKILLS_DIR } from './skills/constants';\nimport type { ISkillMounter } from './skills/ISkillMounter';\n\nexport interface SandboxInfo {\n sandbox_id: string;\n}\n\n// Downloader/setup scripts can run longer than a normal exec.\nexport const SKILL_DOWNLOAD_TIMEOUT_SECONDS = 180;\n\n// Keeps the sandbox-side NATS wait longer than the host's own MCP deadline, so the host\n// surfaces the real MCP error instead of the bridge client giving up first.\nconst NATS_REQUEST_TIMEOUT_BUFFER_SECONDS = 30;\n\n// Write a script (base64-encoded, never interpolated raw) to a path and run it. Skill mounters reuse it.\nexport function buildWriteAndRunScriptCommand(params: { scriptPath: string; scriptContent: string }): string {\n const b64 = Buffer.from(params.scriptContent, 'utf-8').toString('base64');\n return [\n `mkdir -p \"$(dirname ${params.scriptPath})\"`,\n `rm -f ${params.scriptPath}`,\n `echo ${shellEscape(b64)} | base64 -d > ${params.scriptPath}`,\n `chmod a-w ${params.scriptPath}`,\n `python3 ${params.scriptPath}`,\n ].join(' && ');\n}\n\n/** Write or clear the git credential-store file at an absolute path (no global git config mutation). */\nfunction buildSyncGitCredentialsCommand(credentialsContent: string | null, credentialsPath: string): string {\n if (credentialsContent === null) {\n return `rm -f ${credentialsPath}`;\n }\n // Base64 avoids interpolating credentials directly into the shell command.\n const b64 = Buffer.from(credentialsContent, 'utf-8').toString('base64');\n return (\n `mkdir -p \"$(dirname ${credentialsPath})\" && ` +\n `echo '${b64}' | base64 -d > ${credentialsPath} && ` +\n `chmod 600 ${credentialsPath}`\n );\n}\n\n/**\n * Process-scoped credential.helper via GIT_CONFIG_* (avoids shared ~/.gitconfig).\n * Required for TFY sandbox where many logical sandboxes share one pod/HOME.\n */\nfunction buildGitCredentialHelperEnv(credentialsPath: string): Record<string, string> {\n return {\n GIT_CONFIG_COUNT: '1',\n GIT_CONFIG_KEY_0: 'credential.helper',\n GIT_CONFIG_VALUE_0: `store --file ${credentialsPath}`,\n };\n}\n\nexport interface SandboxStoredFile {\n filePath: string;\n sandboxCreated?: SandboxInfo | undefined;\n}\n\nexport interface SandboxOptions {\n provider: SandboxProvider;\n existingSandboxId?: string | undefined;\n skillMounter?: ISkillMounter | undefined;\n fileDownloadEnabled?: boolean | undefined;\n /** Pre-resolved credential-store file content (null = clear / no git auth). */\n resolvedGitCredentialsContent?: string | null | undefined;\n /**\n * Blocks destructive tools in code mode so they go through the approval flow\n * instead. Must be `true` — approvals are always enabled (the kill switch is gone).\n */\n blockDestructiveToolsInCodeMode: true;\n /** Used to derive the Code Mode NATS wait as request + connect. */\n mcpRequestTimeoutMs: number;\n mcpConnectTimeoutMs: number;\n tenantName: string;\n tracing: AgentTracing;\n logger: Logger;\n}\n\nexport const SANDBOX_EXEC_TOOL_NAME = 'exec';\nconst SANDBOX_MCP_REMINDER_TAG = 'sandbox-mcp-code-mode';\nconst SANDBOX_FILE_OUTPUT_TAG = 'sandbox-file-output';\nexport const SANDBOX_SCHEMA_INFER_TAG = 'sandbox-schema-infer';\nconst SANDBOX_FILE_UPLOADS_TAG = 'sandbox-file-uploads';\n\n// Stable directory inside the sandbox where the MCP client library is uploaded.\n// MCP client upload dir. Two lookup channels:\n// - Python imports → PYTHONPATH points here (set by injectMCPClientEnv).\n// - Shell `mcp-client` → symlinked into MCP_CLIENT_BIN_SYMLINK (on default PATH);\n// basename drops `.py` so the agent doesn't reflexively prefix with `python`.\nconst MCP_CLIENT_DIR = '/opt/tfy/mcp-client';\nconst MCP_CLIENT_PATH = `${MCP_CLIENT_DIR}/mcp_client.py`;\nconst MCP_CLIENT_BIN_SYMLINK = '/usr/local/bin/mcp-client';\n\n// Per-server config sent to mcp_client.py via TFY_MCP_SERVERS.\ninterface SandboxMcpServerConfig {\n allowed_tools: string[];\n}\n\n// Snapshots the active W3C trace context (`traceparent` + optional `tracestate`) into env\n// vars consumed by mcp_client.py. Read with TFY_TRACEPARENT / TFY_TRACESTATE on the sandbox\n// side and attached as NATS message headers; the bridge extracts them on each message so the\n// resulting MCP spans nest under the originating Sandbox: exec span.\nfunction injectTraceContextEnv(): Record<string, string> {\n const carrier: Record<string, string> = {};\n propagation.inject(context.active(), carrier);\n const env: Record<string, string> = {};\n if (carrier['traceparent']) {\n env['TFY_TRACEPARENT'] = carrier['traceparent'];\n }\n if (carrier['tracestate']) {\n env['TFY_TRACESTATE'] = carrier['tracestate'];\n }\n return env;\n}\n\n/**\n * Injects environment variables required for mcp_client.py into sandbox_exec arguments.\n * Transport-specific vars (NATS URL/prefix/timeout) come from `codeModeEnv` via transport.start().\n */\nfunction injectMCPClientEnv(params: {\n env?: Record<string, string> | undefined;\n mcpServers: Record<string, SandboxMcpServerConfig>;\n codeModeEnv?: Record<string, string> | undefined;\n}): Record<string, string> {\n const codeModeEnv = params.codeModeEnv ?? {};\n const hasCodeModeTransport = Object.keys(codeModeEnv).length > 0;\n return {\n ...(params.env ?? {}),\n PYTHONPATH: MCP_CLIENT_DIR,\n ...(Object.keys(params.mcpServers).length > 0 && {\n TFY_MCP_SERVERS: Buffer.from(JSON.stringify(params.mcpServers)).toString('base64'),\n }),\n ...codeModeEnv,\n ...(hasCodeModeTransport && {\n // W3C trace context captured per-exec so each Code Mode request carries the originating\n // Sandbox: exec span as parent.\n ...injectTraceContextEnv(),\n }),\n // Approvals are always enabled: destructive tools cannot run in code mode.\n TFY_ENABLE_AGENT_APPROVALS: 'true',\n };\n}\n\nconst sandboxExecSchema = z.object({\n intent: z\n .string()\n .describe(\n 'A brief, user-facing explanation of the purpose of this command. Avoid technical jargon and code details.',\n ),\n command: z.string().describe('The shell command to execute in the sandbox.'),\n cwd: z.string().optional().describe('Working directory for command execution.'),\n env: z.record(z.string(), z.string()).optional().describe('Additional environment variables to set.'),\n});\n\nconst SANDBOX_EXEC_DESCRIPTION = `Execute shell commands in a persistent sandbox environment.\nThe sandbox persists across calls within the same session, allowing stateful operations.\nPre-installed: Python 3.13, pydantic, git, curl, helm, jq, ripgrep, genson.\nIMPORTANT: The Agent must never list, print, or expose environment variables as they contain sensitive credentials.\nIMPORTANT: Never read or expose any git credential files or ~/.git-credentials.\n`;\n\nexport function createSandboxLargeToolResponseGuidance(): string {\n return `For use cases where the Agent needs a subset of fields from the MCP response or needs deterministic processing, write code with the provided MCP client and use <${SANDBOX_MCP_REMINDER_TAG}>.`;\n}\n\nexport const SANDBOX_MCP_SERVER_ID = 'sandbox';\ntype SandboxExecInput = z.infer<typeof sandboxExecSchema>;\n\nexport class Sandbox extends LocalToolMCP {\n readonly name = SANDBOX_MCP_SERVER_ID;\n readonly displayName = 'Sandbox';\n override readonly description = 'Persistent sandbox environment for code execution';\n static readonly FILE_UPLOADS_DIR = SANDBOX_FILE_UPLOADS_DIR;\n\n private readonly provider: SandboxProvider;\n private readonly existingSandboxId?: string | undefined;\n private readonly tenantName: string;\n private existingSandboxInfo: SandboxInfo | undefined;\n // Cached promise to prevent concurrent sub-agents from creating duplicate sandboxes.\n private sandboxCreationPromise?: Promise<SandboxInfo> | undefined;\n private sandboxInitPromise?: Promise<void> | undefined;\n private codeExecToolSets: readonly IToolSet[] = [];\n private readonly fileDownloadEnabled: boolean;\n private readonly requestTimeoutSeconds: number;\n private readonly skillMounter?: ISkillMounter | undefined;\n private cachedSkillsSection?: string | undefined;\n private readonly mcpClientScriptBase64: string;\n private readonly logger: Logger;\n // Pre-resolved credential-store file content (null = clear / no git auth).\n private readonly resolvedGitCredentialsContent: string | null;\n private codeModeDispatcher: CodeModeDispatcher | undefined;\n private codeModeTransport: CodeModeTransport | undefined;\n\n private tools = [\n defineTool({\n name: SANDBOX_EXEC_TOOL_NAME,\n description: SANDBOX_EXEC_DESCRIPTION,\n schema: sandboxExecSchema,\n handler: input => this.handleExec(input),\n }),\n ];\n\n constructor(options: SandboxOptions) {\n super({ tracing: options.tracing });\n this.provider = options.provider;\n this.existingSandboxId = options.existingSandboxId;\n this.tenantName = options.tenantName;\n this.skillMounter = options.skillMounter;\n this.fileDownloadEnabled = options.fileDownloadEnabled ?? false;\n const mcpBoundTimeoutMs = options.mcpRequestTimeoutMs + options.mcpConnectTimeoutMs;\n this.requestTimeoutSeconds = Math.ceil(mcpBoundTimeoutMs / 1000) + NATS_REQUEST_TIMEOUT_BUFFER_SECONDS;\n // Scripts are internal to Sandbox: the upload paths, env contract, and prompt\n // text are all hardcoded here, so injecting different content was never a\n // real extension point. Consumers don't see or provide them.\n this.mcpClientScriptBase64 = Buffer.from(sandboxScripts.mcpClient, 'utf-8').toString('base64');\n this.logger = options.logger.child({ module: 'Sandbox' });\n this.resolvedGitCredentialsContent = options.resolvedGitCredentialsContent ?? null;\n\n if (this.existingSandboxId) {\n validateSandboxOwnedByTenant(this.existingSandboxId, this.tenantName);\n this.existingSandboxInfo = { sandbox_id: this.existingSandboxId };\n }\n }\n\n private buildMcpServersEnvelope(): Record<string, SandboxMcpServerConfig> {\n const out: Record<string, SandboxMcpServerConfig> = {};\n for (const server of this.codeExecToolSets) {\n const allowedTools = server.getAllowedToolNamesForSandbox?.() ?? [];\n out[server.name] = { allowed_tools: allowedTools };\n }\n return out;\n }\n\n protected getTools() {\n return this.tools;\n }\n\n configureCodeMode(servers: readonly IToolSet[]): void {\n if (!servers.length) {\n return;\n }\n if (this.codeModeDispatcher !== undefined || this.codeModeTransport !== undefined) {\n throw new Error('Code Mode is already configured for this Sandbox');\n }\n this.codeExecToolSets = servers;\n this.codeModeDispatcher = new CodeModeDispatcher({ toolSets: servers, logger: this.logger });\n this.codeModeTransport = this.provider.createCodeModeTransport();\n }\n\n estimateSkillsTokens(): number {\n return Math.round(estimateTokensForString(this.renderSkillsSection()));\n }\n\n // Single source for the <skills> section, so the prompt and the token estimate can't drift.\n private renderSkillsSection(): string {\n if (this.cachedSkillsSection === undefined) {\n const skills = new InstructionBuilder('skills');\n this.skillMounter?.instruction(skills);\n this.cachedSkillsSection = skills.build();\n }\n return this.cachedSkillsSection;\n }\n\n buildInstruction(builder: InstructionBuilder): void {\n const providerInstructions = this.provider.getAdditionalInstructions();\n\n const sandboxInstructions = builder.beginSection('sandbox');\n sandboxInstructions.addContent('The Agent has access to a persistent sandbox environment for executing code.');\n sandboxInstructions.addContent('The Agent must NOT read or modify any git credential files.');\n\n this.buildSchemaSection(sandboxInstructions);\n this.buildSkillsSection(sandboxInstructions);\n this.buildMCPSection(sandboxInstructions);\n this.buildFileUploadsSection(sandboxInstructions);\n\n if (this.fileDownloadEnabled) {\n this.buildFileOutputSection(sandboxInstructions);\n }\n\n if (providerInstructions) {\n sandboxInstructions.addContent(providerInstructions);\n }\n }\n\n private buildFileUploadsSection(builder: InstructionBuilder): void {\n builder.addSection(\n SANDBOX_FILE_UPLOADS_TAG,\n `User-uploaded files are placed in ${Sandbox.FILE_UPLOADS_DIR}/. The Agent must not modify files in this directory. Always create a copy at a different location and work on the copy.`,\n );\n }\n\n private buildSchemaSection(builder: InstructionBuilder): void {\n builder.addSection(\n SANDBOX_SCHEMA_INFER_TAG,\n dedent`\n To infer the schema of a file, the Agent MUST execute:\n head -c 200 file_path && echo \"==\" && genson file_path\n This serves two purpose:\n 1. The first 200 characters of the file is printed. Do NOT print more than 200 characters.\n 2. genson infers and returns JSON schema if the file is JSON.\n Even if the file is not JSON, (1) helps to take the next step in less number of turns\n `,\n );\n }\n\n private buildFileOutputSection(builder: InstructionBuilder): void {\n builder.addSection(\n SANDBOX_FILE_OUTPUT_TAG,\n dedent`\n ## File outputs\n\n The user does not have direct access to the sandbox filesystem. When the Agent wants to output a file to the user, it MUST:\n 1. Ensure that the file is present in the sandbox.\n 2. Then emit a fenced sandbox_artifacts block referencing the file.\n\n \\`\\`\\`sandbox_artifacts\n [Human-readable label](/absolute/path/to/file.ext)\n \\`\\`\\`\n\n For large outputs or content that can be generated through code, prefer writing to a file and emitting the sandbox_artifacts block.\n\n Rules:\n - Use absolute paths, not ~/ or relative paths.\n - One link per line. Markdown link syntax: [label](path).`,\n );\n }\n\n private buildSkillsSection(builder: InstructionBuilder): void {\n builder.addContent(this.renderSkillsSection());\n }\n\n private buildMCPSection(builder: InstructionBuilder): void {\n const toolSetNames = this.codeExecToolSets.map(m => m.name).join(',');\n if (!toolSetNames) {\n return;\n }\n\n builder.addSection(\n SANDBOX_MCP_REMINDER_TAG,\n dedent`\n The following MCP servers are accessible for Code Mode from ${SANDBOX_EXEC_TOOL_NAME} via the pre-installed mcp_client module: ${toolSetNames}\n Tool and functions are same.\n Tool and MCP tools are same.\n\n Use \\`from mcp_client import call_tool\\` in code, or \\`mcp-client\\` in shell — both work from any directory with no setup.\n\n For deferred MCP servers, the Agent MUST:\n 1. Discover tools via ${LIST_TOOLS_NAME} from ${DEFERRED_TOOLS_SERVER_ID}. Never call ${GET_TOOL_OUTPUT_SCHEMA_NAME}.\n 2. Discover inputSchema, outputSchema via ${GET_TOOL_INFO_NAME} for the selected tools.\n\n For non-deferred MCP servers, the Agent MUST:\n 1. Discover outputSchema via ${GET_TOOL_OUTPUT_SCHEMA_NAME} for the selected tools.\n\n For both deferred and non-deferred if outputSchema is absent,\n the Agent MUST say: \"Given the MCP Server does not define an outputSchema let me figure it out first before entering Code Mode.\"\n mcp-client call-tool {server} {tool} '{\"param\":\"value\"}' > file && ${SANDBOX_SCHEMA_INFER_TAG}.\n the Agent MUST execute a single command for above.\n If the Agent does not get the outputSchema, the Agent will encounters scenarios like:\n Agent: \"Let me fix the code - it seems the x might be a string rather than an object in some cases:\"\n The Agent MUST not face the above scenario.\n \n Only after the Agent has the above details, it can enter Code Mode.\n Agent: Now I know the inputSchema and outputSchema of tool_a, let me enter Code Mode.\n\n CLI: mcp-client call-tool {server} {tool_a} '{\"param\":\"value\"}'\n # The output of the above command MUST be passed through tools like jq or head -c\n # or should be written to a file. Goal is to reduce content that the Agent prints.\n\n Code: from mcp_client import call_tool\n async def main():\n result = await call_tool(\"{server}\", \"{tool_a}\", body={\"param1\": \"value1\"})\n # In most cases, it is safe to assume response is a dict.\n # To know the dict keys, the Agent still needs to figure out the outputSchema.\n # The Agent MUST write the code to a file first before executing it.\n\n Code Mode MUST be used in following scenarios:\n\n 1. Reduce structured tool output by executing logic on top of it. Never present raw output.\n Agent: Now I know the inputSchema and outputSchema of tool_a, let me enter Code Mode.\n\n from mcp_client import call_tool\n from collections import Counter\n result = await call_tool(\"{server}\", \"{tool_a}\", body={\"param1\": \"value1\"})\n print(Counter(r[\"a\"] for r in result[\"b\"] if r[\"c\"])) # Here we need outputSchema to know a, b and c exists.\n\n 2. Piping result of one tool call to another tool request. Never present raw output.\n Agent: Now I know the inputSchema and outputSchema of tool1, let me enter Code Mode.\n\n from mcp_client import call_tool\n result1 = await call_tool(\"{server}\", \"{tool1}\", body={\"param1\": \"value1\"})\n result2 = await call_tool(\"{server}\", \"{tool2}\", body={\"param2\": result1[\"value1\"]}) # Here we need outputSchema to know value1 exists.\n print(result2) # Note in this case, we do not need outputSchema of result2 as we are not using any fields.\n\n The Agent MUST NOT use Code Mode just to print the full raw response of a tool. The Agent MUST call the tool directly in this case, there are other mechanisms in place to ensure\n large tool call outputs are not loaded in context.\n \n The Agent must not use Code Mode in the following patterns:\n from mcp_client import call_tool\n import json\n result = await call_tool(\"{server}\", \"{tool}\", body={\"param1\": \"value1\"})\n print(result) # Never do this. Call tool without Code Mode.\n print(json.dumps(result, ...)) # Never do this. Call tool without Code Mode.\n print(result[\"a\"][:100]) # Never do this. Call tool without Code Mode.\n\n mcp-client call-tool {server} {tool} '{\"param\":\"value\"}' > data && cat data # Never do this. Call tool without Code Mode.\n `,\n );\n }\n\n private get requiredSandboxInfo(): SandboxInfo {\n if (!this.existingSandboxInfo) {\n throw new Error('Sandbox info not available');\n }\n return this.existingSandboxInfo;\n }\n\n private async ensureSandboxCreated(): Promise<{\n sandboxInfo: SandboxInfo;\n sandboxCreated: SandboxInfo | undefined;\n }> {\n if (this.existingSandboxInfo) {\n return { sandboxInfo: this.existingSandboxInfo, sandboxCreated: undefined };\n }\n // Provider returns camelCase `{ sandboxId }`; rename to the snake_case\n // `SandboxInfo` shape used everywhere downstream (Redis JSON, wire event,\n // in-memory state).\n this.sandboxCreationPromise ??= this.provider\n .createSandbox()\n .then(({ sandboxId }) => {\n validateSandboxOwnedByTenant(sandboxId, this.tenantName);\n return { sandbox_id: sandboxId };\n })\n .catch((e: unknown) => {\n this.sandboxCreationPromise = undefined;\n throw e;\n });\n\n const sandboxInfo = await this.sandboxCreationPromise;\n // Concurrent callers may both await the same creation promise; only the first\n // should report sandboxCreated. Read via a local to avoid control-flow narrowing\n // that treats existingSandboxInfo as always undefined after the early return above.\n const prior = this.existingSandboxInfo as SandboxInfo | undefined;\n const isNew = prior === undefined;\n this.existingSandboxInfo = sandboxInfo;\n return { sandboxInfo, sandboxCreated: isNew ? sandboxInfo : undefined };\n }\n\n private async handleExec(input: SandboxExecInput): Promise<CallToolResponse> {\n const { sandboxInfo, sandboxCreated } = await this.ensureSandboxCreated();\n const sandboxCreatedFlag = Boolean(sandboxCreated);\n try {\n await this.ensureSandboxInitialized();\n } catch (e) {\n this.logger.error('Sandbox initialization failed', extractErrorLogFields(e));\n const message = e instanceof Error ? e.message : 'Sandbox initialization failed';\n return toolResultResponse({\n text: `Sandbox initialization failed: ${message}`,\n isError: true,\n overrides: { sandboxCreated: sandboxCreatedFlag, sandboxInfo },\n });\n }\n const codeModeEnv = await this.ensureCodeModeStarted(sandboxInfo.sandbox_id);\n const mcpClientEnv = injectMCPClientEnv({\n env: input.env,\n mcpServers: this.buildMcpServersEnvelope(),\n codeModeEnv,\n });\n const gitAuthEnv =\n this.resolvedGitCredentialsContent !== null\n ? buildGitCredentialHelperEnv(this.provider.getGitCredentialsPath(sandboxInfo.sandbox_id))\n : {};\n const env = { ...mcpClientEnv, ...gitAuthEnv };\n\n const result = await this.provider.exec({\n sandboxId: sandboxInfo.sandbox_id,\n command: input.command,\n cwd: input.cwd,\n env,\n });\n\n return {\n result: {\n content: [{ type: 'text', text: JSON.stringify(result) }],\n isError: !result.success,\n },\n wasInitialized: undefined,\n sandboxCreated: sandboxCreatedFlag,\n sandboxInfo,\n };\n }\n\n async getToolResultDumpDir(): Promise<{ dir: string; sandboxCreated: SandboxInfo | undefined }> {\n const { sandboxCreated } = await this.ensureSandboxCreated();\n const dir = this.provider.getToolResultDumpDir(this.requiredSandboxInfo.sandbox_id).replace(/\\/+$/, '');\n return { dir, sandboxCreated };\n }\n\n async uploadFile(params: {\n targetDir: string;\n fileName: string;\n content: Buffer;\n }): Promise<{ filePath: string; sandboxCreated: SandboxInfo | undefined }> {\n const { sandboxCreated } = await this.ensureSandboxCreated();\n await this.ensureSandboxInitialized();\n const { sandbox_id: sandboxId } = this.requiredSandboxInfo;\n const fileName = params.fileName.replace(/^\\/+/, '');\n const targetDir = params.targetDir.replace(/\\/+$/, '');\n const filePath = `${targetDir}/${fileName}`;\n await this.provider.uploadFile({ sandboxId, remotePath: filePath, content: params.content });\n return { filePath, sandboxCreated };\n }\n\n async uploadUserFile(input: { fileName: string; content: Buffer; mime: string }): Promise<SandboxStoredFile> {\n validateNoPathTraversal(input.fileName);\n const result = await this.uploadFile({\n targetDir: Sandbox.FILE_UPLOADS_DIR,\n fileName: input.fileName,\n content: input.content,\n });\n return {\n filePath: result.filePath,\n ...(result.sandboxCreated && { sandboxCreated: result.sandboxCreated }),\n };\n }\n\n async writeArtifact(input: {\n fileName: string;\n content: Buffer;\n sourceTool?: MappedMCPTool | undefined;\n }): Promise<SandboxStoredFile> {\n validateNoPathTraversal(input.fileName);\n const { dir, sandboxCreated: dumpDirCreated } = await this.getToolResultDumpDir();\n const result = await this.uploadFile({\n targetDir: dir,\n fileName: input.fileName,\n content: input.content,\n });\n const sandboxCreated = dumpDirCreated ?? result.sandboxCreated;\n return {\n filePath: result.filePath,\n ...(sandboxCreated && { sandboxCreated }),\n };\n }\n\n /**\n * Upload the MCP client Python script to the sandbox on the first tool call.\n * The sandbox filesystem is persistent within a session, so this only needs to run once.\n */\n private async ensureSandboxInitialized(): Promise<void> {\n this.sandboxInitPromise ??= this.initSandboxEnvironment().catch((e: unknown) => {\n this.sandboxInitPromise = undefined;\n throw e;\n });\n await this.sandboxInitPromise;\n }\n\n private async writeGitCredentials(): Promise<void> {\n const sandboxId = this.requiredSandboxInfo.sandbox_id;\n const credentialsPath = this.provider.getGitCredentialsPath(sandboxId);\n const result = await this.provider.exec({\n sandboxId,\n command: buildSyncGitCredentialsCommand(this.resolvedGitCredentialsContent, credentialsPath),\n });\n ensureExecSuccess(result);\n }\n\n private async initSandboxEnvironment(): Promise<void> {\n const toolResultDumpDir = this.provider.getToolResultDumpDir(this.requiredSandboxInfo.sandbox_id);\n\n this.logger.info('Uploading MCP client script and preparing skills directory in sandbox');\n\n const initSteps = [\n `mkdir -p ${MCP_CLIENT_DIR} ${Sandbox.FILE_UPLOADS_DIR} ${toolResultDumpDir} ${SKILLS_DIR}`,\n `rm -f ${MCP_CLIENT_PATH}`,\n `echo '${this.mcpClientScriptBase64}' | base64 -d > ${MCP_CLIENT_PATH}`,\n // Make executable + symlink onto PATH so `mcp-client` runs by name.\n `chmod 0555 ${MCP_CLIENT_PATH}`,\n `ln -sf ${MCP_CLIENT_PATH} ${MCP_CLIENT_BIN_SYMLINK}`,\n ];\n\n // Fold skill installation into this single init exec. The mounter returns a declarative\n // command + env + timeout; runs even when empty so its downloader can prune a reused sandbox.\n const skillInit = this.skillMounter?.getSandboxInit();\n if (skillInit) {\n initSteps.push(skillInit.command);\n }\n\n const script = initSteps.join(' && ');\n const result = await this.provider.exec({\n sandboxId: this.requiredSandboxInfo.sandbox_id,\n command: script,\n env: skillInit?.env,\n timeoutSeconds: skillInit?.timeoutSeconds,\n });\n ensureExecSuccess(result);\n this.logger.info(\n `Sandbox initialized: MCP client at ${MCP_CLIENT_PATH} (symlinked to ${MCP_CLIENT_BIN_SYMLINK}); skills dir ${SKILLS_DIR}`,\n );\n\n await this.writeGitCredentials();\n }\n\n /**\n * Lazily start Code Mode transport. Single-flight and idempotent inside the transport.\n * Skipped when code mode was never configured. Connect failures resolve to `undefined`\n * (no Code Mode env for that exec); mcp_client then errors at call time — no HTTPS fallback.\n */\n private async ensureCodeModeStarted(sandboxId: string): Promise<Record<string, string> | undefined> {\n const dispatcher = this.codeModeDispatcher;\n const transport = this.codeModeTransport;\n if (dispatcher === undefined || transport === undefined) {\n return undefined;\n }\n try {\n const { env } = await transport.start({\n codeModeDispatcher: dispatcher,\n sandboxId,\n requestTimeoutSeconds: this.requestTimeoutSeconds,\n });\n return env;\n } catch (e) {\n // Connect-level errors are not sticky: transport clears its session so a later exec may retry.\n this.logger.error('Code Mode transport start failed', {\n ...extractErrorLogFields(e),\n sandboxId,\n });\n return undefined;\n }\n }\n\n // Tears down per-Sandbox Code Mode resources. Dispatcher close first (in-flight requests get\n // source: transport), then transport.stop() — always safe whether start succeeded, failed, or never ran.\n async close(): Promise<void> {\n this.codeModeDispatcher?.close();\n this.codeModeDispatcher = undefined;\n const transport = this.codeModeTransport;\n this.codeModeTransport = undefined;\n if (transport === undefined) {\n return;\n }\n try {\n await transport.stop();\n } catch (e) {\n this.logger.warn('Error stopping Code Mode transport', extractErrorLogFields(e));\n }\n }\n}\n"],"mappings":"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","names":["import_constants","dedent"]}
@@ -69,7 +69,6 @@ function injectMCPClientEnv(params) {
69
69
  const codeModeEnv = params.codeModeEnv ?? {};
70
70
  const hasCodeModeTransport = Object.keys(codeModeEnv).length > 0;
71
71
  return {
72
- ...params.execExtraEnv ?? {},
73
72
  ...params.env ?? {},
74
73
  PYTHONPATH: MCP_CLIENT_DIR,
75
74
  ...Object.keys(params.mcpServers).length > 0 && {
@@ -118,7 +117,6 @@ var Sandbox = class _Sandbox extends LocalToolMCP {
118
117
  codeExecToolSets = [];
119
118
  fileDownloadEnabled;
120
119
  requestTimeoutSeconds;
121
- execExtraEnv;
122
120
  skillMounter;
123
121
  cachedSkillsSection;
124
122
  mcpClientScriptBase64;
@@ -139,12 +137,11 @@ var Sandbox = class _Sandbox extends LocalToolMCP {
139
137
  super({ tracing: options.tracing });
140
138
  this.provider = options.provider;
141
139
  this.existingSandboxId = options.existingSandboxId;
142
- this.tenantName = options.execExtraEnv?.["TFY_TENANT_NAME"] ?? "";
140
+ this.tenantName = options.tenantName;
143
141
  this.skillMounter = options.skillMounter;
144
142
  this.fileDownloadEnabled = options.fileDownloadEnabled ?? false;
145
143
  const mcpBoundTimeoutMs = options.mcpRequestTimeoutMs + options.mcpConnectTimeoutMs;
146
144
  this.requestTimeoutSeconds = Math.ceil(mcpBoundTimeoutMs / 1e3) + NATS_REQUEST_TIMEOUT_BUFFER_SECONDS;
147
- this.execExtraEnv = options.execExtraEnv;
148
145
  this.mcpClientScriptBase64 = Buffer.from(sandboxScripts.mcpClient, "utf-8").toString("base64");
149
146
  this.logger = options.logger.child({ module: "Sandbox" });
150
147
  this.resolvedGitCredentialsContent = options.resolvedGitCredentialsContent ?? null;
@@ -363,7 +360,6 @@ var Sandbox = class _Sandbox extends LocalToolMCP {
363
360
  const mcpClientEnv = injectMCPClientEnv({
364
361
  env: input.env,
365
362
  mcpServers: this.buildMcpServersEnvelope(),
366
- execExtraEnv: this.execExtraEnv,
367
363
  codeModeEnv
368
364
  });
369
365
  const gitAuthEnv = this.resolvedGitCredentialsContent !== null ? buildGitCredentialHelperEnv(this.provider.getGitCredentialsPath(sandboxInfo.sandbox_id)) : {};
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/core/sandbox/Sandbox.ts"],"sourcesContent":["import { context, propagation } from '@opentelemetry/api';\nimport dedent from 'dedent';\nimport type { Logger } from 'winston';\nimport { z } from 'zod';\nimport { InstructionBuilder } from '../InstructionBuilder';\nimport { estimateTokensForString } from '../llm/usage';\nimport type { MappedMCPTool } from '../mcp/convertMCPServers';\nimport { toolResultResponse, type CallToolResponse, type IToolSet } from '../mcp/IMCPServer';\nimport { defineTool, LocalToolMCP } from '../mcp/LocalToolMCP';\nimport {\n DEFERRED_TOOLS_SERVER_ID,\n GET_TOOL_INFO_NAME,\n GET_TOOL_OUTPUT_SCHEMA_NAME,\n LIST_TOOLS_NAME,\n} from '../runtime/DeferredTool';\nimport type { AgentTracing } from '../tracing/AgentTracing';\nimport { extractErrorLogFields } from '../util/errorLogFields';\nimport { CodeModeDispatcher } from './codeMode/CodeModeDispatcher';\nimport type { CodeModeTransport } from './codeMode/CodeModeTransport';\nimport { SANDBOX_FILE_UPLOADS_DIR } from './constants';\nimport { ensureExecSuccess, shellEscape, type SandboxProvider } from './provider/Provider';\nimport { validateNoPathTraversal, validateSandboxOwnedByTenant } from './SandboxErrors';\nimport { sandboxScripts } from './sandboxScripts.gen';\n// Import submodules, not the ./skills barrel, to avoid a cycle (the mounters import from Sandbox).\nimport { SKILLS_DIR } from './skills/constants';\nimport type { ISkillMounter } from './skills/ISkillMounter';\n\nexport interface SandboxInfo {\n sandbox_id: string;\n}\n\n// Downloader/setup scripts can run longer than a normal exec.\nexport const SKILL_DOWNLOAD_TIMEOUT_SECONDS = 180;\n\n// Keeps the sandbox-side NATS wait longer than the host's own MCP deadline, so the host\n// surfaces the real MCP error instead of the bridge client giving up first.\nconst NATS_REQUEST_TIMEOUT_BUFFER_SECONDS = 30;\n\n// Write a script (base64-encoded, never interpolated raw) to a path and run it. Skill mounters reuse it.\nexport function buildWriteAndRunScriptCommand(params: { scriptPath: string; scriptContent: string }): string {\n const b64 = Buffer.from(params.scriptContent, 'utf-8').toString('base64');\n return [\n `mkdir -p \"$(dirname ${params.scriptPath})\"`,\n `rm -f ${params.scriptPath}`,\n `echo ${shellEscape(b64)} | base64 -d > ${params.scriptPath}`,\n `chmod a-w ${params.scriptPath}`,\n `python3 ${params.scriptPath}`,\n ].join(' && ');\n}\n\n/** Write or clear the git credential-store file at an absolute path (no global git config mutation). */\nfunction buildSyncGitCredentialsCommand(credentialsContent: string | null, credentialsPath: string): string {\n if (credentialsContent === null) {\n return `rm -f ${credentialsPath}`;\n }\n // Base64 avoids interpolating credentials directly into the shell command.\n const b64 = Buffer.from(credentialsContent, 'utf-8').toString('base64');\n return (\n `mkdir -p \"$(dirname ${credentialsPath})\" && ` +\n `echo '${b64}' | base64 -d > ${credentialsPath} && ` +\n `chmod 600 ${credentialsPath}`\n );\n}\n\n/**\n * Process-scoped credential.helper via GIT_CONFIG_* (avoids shared ~/.gitconfig).\n * Required for TFY sandbox where many logical sandboxes share one pod/HOME.\n */\nfunction buildGitCredentialHelperEnv(credentialsPath: string): Record<string, string> {\n return {\n GIT_CONFIG_COUNT: '1',\n GIT_CONFIG_KEY_0: 'credential.helper',\n GIT_CONFIG_VALUE_0: `store --file ${credentialsPath}`,\n };\n}\n\nexport interface SandboxStoredFile {\n filePath: string;\n sandboxCreated?: SandboxInfo | undefined;\n}\n\nexport interface SandboxOptions {\n provider: SandboxProvider;\n existingSandboxId?: string | undefined;\n skillMounter?: ISkillMounter | undefined;\n fileDownloadEnabled?: boolean | undefined;\n /** Pre-resolved credential-store file content (null = clear / no git auth). */\n resolvedGitCredentialsContent?: string | null | undefined;\n /**\n * Blocks destructive tools in code mode so they go through the approval flow\n * instead. Must be `true` — approvals are always enabled (the kill switch is gone).\n */\n blockDestructiveToolsInCodeMode: true;\n /** Used to derive the Code Mode NATS wait as request + connect. */\n mcpRequestTimeoutMs: number;\n mcpConnectTimeoutMs: number;\n execExtraEnv?: Readonly<Record<string, string>> | undefined;\n tracing: AgentTracing;\n logger: Logger;\n}\n\nexport const SANDBOX_EXEC_TOOL_NAME = 'exec';\nconst SANDBOX_MCP_REMINDER_TAG = 'sandbox-mcp-code-mode';\nconst SANDBOX_FILE_OUTPUT_TAG = 'sandbox-file-output';\nexport const SANDBOX_SCHEMA_INFER_TAG = 'sandbox-schema-infer';\nconst SANDBOX_FILE_UPLOADS_TAG = 'sandbox-file-uploads';\n\n// Stable directory inside the sandbox where the MCP client library is uploaded.\n// MCP client upload dir. Two lookup channels:\n// - Python imports → PYTHONPATH points here (set by injectMCPClientEnv).\n// - Shell `mcp-client` → symlinked into MCP_CLIENT_BIN_SYMLINK (on default PATH);\n// basename drops `.py` so the agent doesn't reflexively prefix with `python`.\nconst MCP_CLIENT_DIR = '/opt/tfy/mcp-client';\nconst MCP_CLIENT_PATH = `${MCP_CLIENT_DIR}/mcp_client.py`;\nconst MCP_CLIENT_BIN_SYMLINK = '/usr/local/bin/mcp-client';\n\n// Per-server config sent to mcp_client.py via TFY_MCP_SERVERS.\ninterface SandboxMcpServerConfig {\n allowed_tools: string[];\n}\n\n// Snapshots the active W3C trace context (`traceparent` + optional `tracestate`) into env\n// vars consumed by mcp_client.py. Read with TFY_TRACEPARENT / TFY_TRACESTATE on the sandbox\n// side and attached as NATS message headers; the bridge extracts them on each message so the\n// resulting MCP spans nest under the originating Sandbox: exec span.\nfunction injectTraceContextEnv(): Record<string, string> {\n const carrier: Record<string, string> = {};\n propagation.inject(context.active(), carrier);\n const env: Record<string, string> = {};\n if (carrier['traceparent']) {\n env['TFY_TRACEPARENT'] = carrier['traceparent'];\n }\n if (carrier['tracestate']) {\n env['TFY_TRACESTATE'] = carrier['tracestate'];\n }\n return env;\n}\n\n/**\n * Injects environment variables required for mcp_client.py into sandbox_exec arguments.\n * Transport-specific vars (NATS URL/prefix/timeout) come from `codeModeEnv` via transport.start().\n */\nfunction injectMCPClientEnv(params: {\n env?: Record<string, string> | undefined;\n mcpServers: Record<string, SandboxMcpServerConfig>;\n execExtraEnv?: Readonly<Record<string, string>> | undefined;\n codeModeEnv?: Record<string, string> | undefined;\n}): Record<string, string> {\n const codeModeEnv = params.codeModeEnv ?? {};\n const hasCodeModeTransport = Object.keys(codeModeEnv).length > 0;\n return {\n ...(params.execExtraEnv ?? {}),\n ...(params.env ?? {}),\n PYTHONPATH: MCP_CLIENT_DIR,\n ...(Object.keys(params.mcpServers).length > 0 && {\n TFY_MCP_SERVERS: Buffer.from(JSON.stringify(params.mcpServers)).toString('base64'),\n }),\n ...codeModeEnv,\n ...(hasCodeModeTransport && {\n // W3C trace context captured per-exec so each Code Mode request carries the originating\n // Sandbox: exec span as parent.\n ...injectTraceContextEnv(),\n }),\n // Approvals are always enabled: destructive tools cannot run in code mode.\n TFY_ENABLE_AGENT_APPROVALS: 'true',\n };\n}\n\nconst sandboxExecSchema = z.object({\n intent: z\n .string()\n .describe(\n 'A brief, user-facing explanation of the purpose of this command. Avoid technical jargon and code details.',\n ),\n command: z.string().describe('The shell command to execute in the sandbox.'),\n cwd: z.string().optional().describe('Working directory for command execution.'),\n env: z.record(z.string(), z.string()).optional().describe('Additional environment variables to set.'),\n});\n\nconst SANDBOX_EXEC_DESCRIPTION = `Execute shell commands in a persistent sandbox environment.\nThe sandbox persists across calls within the same session, allowing stateful operations.\nPre-installed: Python 3.13, pydantic, git, curl, helm, jq, ripgrep, genson.\nIMPORTANT: The Agent must never list, print, or expose environment variables as they contain sensitive credentials.\nIMPORTANT: Never read or expose any git credential files or ~/.git-credentials.\n`;\n\nexport function createSandboxLargeToolResponseGuidance(): string {\n return `For use cases where the Agent needs a subset of fields from the MCP response or needs deterministic processing, write code with the provided MCP client and use <${SANDBOX_MCP_REMINDER_TAG}>.`;\n}\n\nexport const SANDBOX_MCP_SERVER_ID = 'sandbox';\ntype SandboxExecInput = z.infer<typeof sandboxExecSchema>;\n\nexport class Sandbox extends LocalToolMCP {\n readonly name = SANDBOX_MCP_SERVER_ID;\n readonly displayName = 'Sandbox';\n override readonly description = 'Persistent sandbox environment for code execution';\n static readonly FILE_UPLOADS_DIR = SANDBOX_FILE_UPLOADS_DIR;\n\n private readonly provider: SandboxProvider;\n private readonly existingSandboxId?: string | undefined;\n private readonly tenantName: string;\n private existingSandboxInfo: SandboxInfo | undefined;\n // Cached promise to prevent concurrent sub-agents from creating duplicate sandboxes.\n private sandboxCreationPromise?: Promise<SandboxInfo> | undefined;\n private sandboxInitPromise?: Promise<void> | undefined;\n private codeExecToolSets: readonly IToolSet[] = [];\n private readonly fileDownloadEnabled: boolean;\n private readonly requestTimeoutSeconds: number;\n private readonly execExtraEnv?: Readonly<Record<string, string>> | undefined;\n private readonly skillMounter?: ISkillMounter | undefined;\n private cachedSkillsSection?: string | undefined;\n private readonly mcpClientScriptBase64: string;\n private readonly logger: Logger;\n // Pre-resolved credential-store file content (null = clear / no git auth).\n private readonly resolvedGitCredentialsContent: string | null;\n private codeModeDispatcher: CodeModeDispatcher | undefined;\n private codeModeTransport: CodeModeTransport | undefined;\n\n private tools = [\n defineTool({\n name: SANDBOX_EXEC_TOOL_NAME,\n description: SANDBOX_EXEC_DESCRIPTION,\n schema: sandboxExecSchema,\n handler: input => this.handleExec(input),\n }),\n ];\n\n constructor(options: SandboxOptions) {\n super({ tracing: options.tracing });\n this.provider = options.provider;\n this.existingSandboxId = options.existingSandboxId;\n this.tenantName = options.execExtraEnv?.['TFY_TENANT_NAME'] ?? '';\n this.skillMounter = options.skillMounter;\n this.fileDownloadEnabled = options.fileDownloadEnabled ?? false;\n const mcpBoundTimeoutMs = options.mcpRequestTimeoutMs + options.mcpConnectTimeoutMs;\n this.requestTimeoutSeconds = Math.ceil(mcpBoundTimeoutMs / 1000) + NATS_REQUEST_TIMEOUT_BUFFER_SECONDS;\n this.execExtraEnv = options.execExtraEnv;\n // Scripts are internal to Sandbox: the upload paths, env contract, and prompt\n // text are all hardcoded here, so injecting different content was never a\n // real extension point. Consumers don't see or provide them.\n this.mcpClientScriptBase64 = Buffer.from(sandboxScripts.mcpClient, 'utf-8').toString('base64');\n this.logger = options.logger.child({ module: 'Sandbox' });\n this.resolvedGitCredentialsContent = options.resolvedGitCredentialsContent ?? null;\n\n if (this.existingSandboxId) {\n validateSandboxOwnedByTenant(this.existingSandboxId, this.tenantName);\n this.existingSandboxInfo = { sandbox_id: this.existingSandboxId };\n }\n }\n\n private buildMcpServersEnvelope(): Record<string, SandboxMcpServerConfig> {\n const out: Record<string, SandboxMcpServerConfig> = {};\n for (const server of this.codeExecToolSets) {\n const allowedTools = server.getAllowedToolNamesForSandbox?.() ?? [];\n out[server.name] = { allowed_tools: allowedTools };\n }\n return out;\n }\n\n protected getTools() {\n return this.tools;\n }\n\n configureCodeMode(servers: readonly IToolSet[]): void {\n if (!servers.length) {\n return;\n }\n if (this.codeModeDispatcher !== undefined || this.codeModeTransport !== undefined) {\n throw new Error('Code Mode is already configured for this Sandbox');\n }\n this.codeExecToolSets = servers;\n this.codeModeDispatcher = new CodeModeDispatcher({ toolSets: servers, logger: this.logger });\n this.codeModeTransport = this.provider.createCodeModeTransport();\n }\n\n estimateSkillsTokens(): number {\n return Math.round(estimateTokensForString(this.renderSkillsSection()));\n }\n\n // Single source for the <skills> section, so the prompt and the token estimate can't drift.\n private renderSkillsSection(): string {\n if (this.cachedSkillsSection === undefined) {\n const skills = new InstructionBuilder('skills');\n this.skillMounter?.instruction(skills);\n this.cachedSkillsSection = skills.build();\n }\n return this.cachedSkillsSection;\n }\n\n buildInstruction(builder: InstructionBuilder): void {\n const providerInstructions = this.provider.getAdditionalInstructions();\n\n const sandboxInstructions = builder.beginSection('sandbox');\n sandboxInstructions.addContent('The Agent has access to a persistent sandbox environment for executing code.');\n sandboxInstructions.addContent('The Agent must NOT read or modify any git credential files.');\n\n this.buildSchemaSection(sandboxInstructions);\n this.buildSkillsSection(sandboxInstructions);\n this.buildMCPSection(sandboxInstructions);\n this.buildFileUploadsSection(sandboxInstructions);\n\n if (this.fileDownloadEnabled) {\n this.buildFileOutputSection(sandboxInstructions);\n }\n\n if (providerInstructions) {\n sandboxInstructions.addContent(providerInstructions);\n }\n }\n\n private buildFileUploadsSection(builder: InstructionBuilder): void {\n builder.addSection(\n SANDBOX_FILE_UPLOADS_TAG,\n `User-uploaded files are placed in ${Sandbox.FILE_UPLOADS_DIR}/. The Agent must not modify files in this directory. Always create a copy at a different location and work on the copy.`,\n );\n }\n\n private buildSchemaSection(builder: InstructionBuilder): void {\n builder.addSection(\n SANDBOX_SCHEMA_INFER_TAG,\n dedent`\n To infer the schema of a file, the Agent MUST execute:\n head -c 200 file_path && echo \"==\" && genson file_path\n This serves two purpose:\n 1. The first 200 characters of the file is printed. Do NOT print more than 200 characters.\n 2. genson infers and returns JSON schema if the file is JSON.\n Even if the file is not JSON, (1) helps to take the next step in less number of turns\n `,\n );\n }\n\n private buildFileOutputSection(builder: InstructionBuilder): void {\n builder.addSection(\n SANDBOX_FILE_OUTPUT_TAG,\n dedent`\n ## File outputs\n\n The user does not have direct access to the sandbox filesystem. When the Agent wants to output a file to the user, it MUST:\n 1. Ensure that the file is present in the sandbox.\n 2. Then emit a fenced sandbox_artifacts block referencing the file.\n\n \\`\\`\\`sandbox_artifacts\n [Human-readable label](/absolute/path/to/file.ext)\n \\`\\`\\`\n\n For large outputs or content that can be generated through code, prefer writing to a file and emitting the sandbox_artifacts block.\n\n Rules:\n - Use absolute paths, not ~/ or relative paths.\n - One link per line. Markdown link syntax: [label](path).`,\n );\n }\n\n private buildSkillsSection(builder: InstructionBuilder): void {\n builder.addContent(this.renderSkillsSection());\n }\n\n private buildMCPSection(builder: InstructionBuilder): void {\n const toolSetNames = this.codeExecToolSets.map(m => m.name).join(',');\n if (!toolSetNames) {\n return;\n }\n\n builder.addSection(\n SANDBOX_MCP_REMINDER_TAG,\n dedent`\n The following MCP servers are accessible for Code Mode from ${SANDBOX_EXEC_TOOL_NAME} via the pre-installed mcp_client module: ${toolSetNames}\n Tool and functions are same.\n Tool and MCP tools are same.\n\n Use \\`from mcp_client import call_tool\\` in code, or \\`mcp-client\\` in shell — both work from any directory with no setup.\n\n For deferred MCP servers, the Agent MUST:\n 1. Discover tools via ${LIST_TOOLS_NAME} from ${DEFERRED_TOOLS_SERVER_ID}. Never call ${GET_TOOL_OUTPUT_SCHEMA_NAME}.\n 2. Discover inputSchema, outputSchema via ${GET_TOOL_INFO_NAME} for the selected tools.\n\n For non-deferred MCP servers, the Agent MUST:\n 1. Discover outputSchema via ${GET_TOOL_OUTPUT_SCHEMA_NAME} for the selected tools.\n\n For both deferred and non-deferred if outputSchema is absent,\n the Agent MUST say: \"Given the MCP Server does not define an outputSchema let me figure it out first before entering Code Mode.\"\n mcp-client call-tool {server} {tool} '{\"param\":\"value\"}' > file && ${SANDBOX_SCHEMA_INFER_TAG}.\n the Agent MUST execute a single command for above.\n If the Agent does not get the outputSchema, the Agent will encounters scenarios like:\n Agent: \"Let me fix the code - it seems the x might be a string rather than an object in some cases:\"\n The Agent MUST not face the above scenario.\n \n Only after the Agent has the above details, it can enter Code Mode.\n Agent: Now I know the inputSchema and outputSchema of tool_a, let me enter Code Mode.\n\n CLI: mcp-client call-tool {server} {tool_a} '{\"param\":\"value\"}'\n # The output of the above command MUST be passed through tools like jq or head -c\n # or should be written to a file. Goal is to reduce content that the Agent prints.\n\n Code: from mcp_client import call_tool\n async def main():\n result = await call_tool(\"{server}\", \"{tool_a}\", body={\"param1\": \"value1\"})\n # In most cases, it is safe to assume response is a dict.\n # To know the dict keys, the Agent still needs to figure out the outputSchema.\n # The Agent MUST write the code to a file first before executing it.\n\n Code Mode MUST be used in following scenarios:\n\n 1. Reduce structured tool output by executing logic on top of it. Never present raw output.\n Agent: Now I know the inputSchema and outputSchema of tool_a, let me enter Code Mode.\n\n from mcp_client import call_tool\n from collections import Counter\n result = await call_tool(\"{server}\", \"{tool_a}\", body={\"param1\": \"value1\"})\n print(Counter(r[\"a\"] for r in result[\"b\"] if r[\"c\"])) # Here we need outputSchema to know a, b and c exists.\n\n 2. Piping result of one tool call to another tool request. Never present raw output.\n Agent: Now I know the inputSchema and outputSchema of tool1, let me enter Code Mode.\n\n from mcp_client import call_tool\n result1 = await call_tool(\"{server}\", \"{tool1}\", body={\"param1\": \"value1\"})\n result2 = await call_tool(\"{server}\", \"{tool2}\", body={\"param2\": result1[\"value1\"]}) # Here we need outputSchema to know value1 exists.\n print(result2) # Note in this case, we do not need outputSchema of result2 as we are not using any fields.\n\n The Agent MUST NOT use Code Mode just to print the full raw response of a tool. The Agent MUST call the tool directly in this case, there are other mechanisms in place to ensure\n large tool call outputs are not loaded in context.\n \n The Agent must not use Code Mode in the following patterns:\n from mcp_client import call_tool\n import json\n result = await call_tool(\"{server}\", \"{tool}\", body={\"param1\": \"value1\"})\n print(result) # Never do this. Call tool without Code Mode.\n print(json.dumps(result, ...)) # Never do this. Call tool without Code Mode.\n print(result[\"a\"][:100]) # Never do this. Call tool without Code Mode.\n\n mcp-client call-tool {server} {tool} '{\"param\":\"value\"}' > data && cat data # Never do this. Call tool without Code Mode.\n `,\n );\n }\n\n private get requiredSandboxInfo(): SandboxInfo {\n if (!this.existingSandboxInfo) {\n throw new Error('Sandbox info not available');\n }\n return this.existingSandboxInfo;\n }\n\n private async ensureSandboxCreated(): Promise<{\n sandboxInfo: SandboxInfo;\n sandboxCreated: SandboxInfo | undefined;\n }> {\n if (this.existingSandboxInfo) {\n return { sandboxInfo: this.existingSandboxInfo, sandboxCreated: undefined };\n }\n // Provider returns camelCase `{ sandboxId }`; rename to the snake_case\n // `SandboxInfo` shape used everywhere downstream (Redis JSON, wire event,\n // in-memory state).\n this.sandboxCreationPromise ??= this.provider\n .createSandbox()\n .then(({ sandboxId }) => {\n validateSandboxOwnedByTenant(sandboxId, this.tenantName);\n return { sandbox_id: sandboxId };\n })\n .catch((e: unknown) => {\n this.sandboxCreationPromise = undefined;\n throw e;\n });\n\n const sandboxInfo = await this.sandboxCreationPromise;\n // Concurrent callers may both await the same creation promise; only the first\n // should report sandboxCreated. Read via a local to avoid control-flow narrowing\n // that treats existingSandboxInfo as always undefined after the early return above.\n const prior = this.existingSandboxInfo as SandboxInfo | undefined;\n const isNew = prior === undefined;\n this.existingSandboxInfo = sandboxInfo;\n return { sandboxInfo, sandboxCreated: isNew ? sandboxInfo : undefined };\n }\n\n private async handleExec(input: SandboxExecInput): Promise<CallToolResponse> {\n const { sandboxInfo, sandboxCreated } = await this.ensureSandboxCreated();\n const sandboxCreatedFlag = Boolean(sandboxCreated);\n try {\n await this.ensureSandboxInitialized();\n } catch (e) {\n this.logger.error('Sandbox initialization failed', extractErrorLogFields(e));\n const message = e instanceof Error ? e.message : 'Sandbox initialization failed';\n return toolResultResponse({\n text: `Sandbox initialization failed: ${message}`,\n isError: true,\n overrides: { sandboxCreated: sandboxCreatedFlag, sandboxInfo },\n });\n }\n const codeModeEnv = await this.ensureCodeModeStarted(sandboxInfo.sandbox_id);\n const mcpClientEnv = injectMCPClientEnv({\n env: input.env,\n mcpServers: this.buildMcpServersEnvelope(),\n execExtraEnv: this.execExtraEnv,\n codeModeEnv,\n });\n const gitAuthEnv =\n this.resolvedGitCredentialsContent !== null\n ? buildGitCredentialHelperEnv(this.provider.getGitCredentialsPath(sandboxInfo.sandbox_id))\n : {};\n const env = { ...mcpClientEnv, ...gitAuthEnv };\n\n const result = await this.provider.exec({\n sandboxId: sandboxInfo.sandbox_id,\n command: input.command,\n cwd: input.cwd,\n env,\n });\n\n return {\n result: {\n content: [{ type: 'text', text: JSON.stringify(result) }],\n isError: !result.success,\n },\n wasInitialized: undefined,\n sandboxCreated: sandboxCreatedFlag,\n sandboxInfo,\n };\n }\n\n async getToolResultDumpDir(): Promise<{ dir: string; sandboxCreated: SandboxInfo | undefined }> {\n const { sandboxCreated } = await this.ensureSandboxCreated();\n const dir = this.provider.getToolResultDumpDir(this.requiredSandboxInfo.sandbox_id).replace(/\\/+$/, '');\n return { dir, sandboxCreated };\n }\n\n async uploadFile(params: {\n targetDir: string;\n fileName: string;\n content: Buffer;\n }): Promise<{ filePath: string; sandboxCreated: SandboxInfo | undefined }> {\n const { sandboxCreated } = await this.ensureSandboxCreated();\n await this.ensureSandboxInitialized();\n const { sandbox_id: sandboxId } = this.requiredSandboxInfo;\n const fileName = params.fileName.replace(/^\\/+/, '');\n const targetDir = params.targetDir.replace(/\\/+$/, '');\n const filePath = `${targetDir}/${fileName}`;\n await this.provider.uploadFile({ sandboxId, remotePath: filePath, content: params.content });\n return { filePath, sandboxCreated };\n }\n\n async uploadUserFile(input: { fileName: string; content: Buffer; mime: string }): Promise<SandboxStoredFile> {\n validateNoPathTraversal(input.fileName);\n const result = await this.uploadFile({\n targetDir: Sandbox.FILE_UPLOADS_DIR,\n fileName: input.fileName,\n content: input.content,\n });\n return {\n filePath: result.filePath,\n ...(result.sandboxCreated && { sandboxCreated: result.sandboxCreated }),\n };\n }\n\n async writeArtifact(input: {\n fileName: string;\n content: Buffer;\n sourceTool?: MappedMCPTool | undefined;\n }): Promise<SandboxStoredFile> {\n validateNoPathTraversal(input.fileName);\n const { dir, sandboxCreated: dumpDirCreated } = await this.getToolResultDumpDir();\n const result = await this.uploadFile({\n targetDir: dir,\n fileName: input.fileName,\n content: input.content,\n });\n const sandboxCreated = dumpDirCreated ?? result.sandboxCreated;\n return {\n filePath: result.filePath,\n ...(sandboxCreated && { sandboxCreated }),\n };\n }\n\n /**\n * Upload the MCP client Python script to the sandbox on the first tool call.\n * The sandbox filesystem is persistent within a session, so this only needs to run once.\n */\n private async ensureSandboxInitialized(): Promise<void> {\n this.sandboxInitPromise ??= this.initSandboxEnvironment().catch((e: unknown) => {\n this.sandboxInitPromise = undefined;\n throw e;\n });\n await this.sandboxInitPromise;\n }\n\n private async writeGitCredentials(): Promise<void> {\n const sandboxId = this.requiredSandboxInfo.sandbox_id;\n const credentialsPath = this.provider.getGitCredentialsPath(sandboxId);\n const result = await this.provider.exec({\n sandboxId,\n command: buildSyncGitCredentialsCommand(this.resolvedGitCredentialsContent, credentialsPath),\n });\n ensureExecSuccess(result);\n }\n\n private async initSandboxEnvironment(): Promise<void> {\n const toolResultDumpDir = this.provider.getToolResultDumpDir(this.requiredSandboxInfo.sandbox_id);\n\n this.logger.info('Uploading MCP client script and preparing skills directory in sandbox');\n\n const initSteps = [\n `mkdir -p ${MCP_CLIENT_DIR} ${Sandbox.FILE_UPLOADS_DIR} ${toolResultDumpDir} ${SKILLS_DIR}`,\n `rm -f ${MCP_CLIENT_PATH}`,\n `echo '${this.mcpClientScriptBase64}' | base64 -d > ${MCP_CLIENT_PATH}`,\n // Make executable + symlink onto PATH so `mcp-client` runs by name.\n `chmod 0555 ${MCP_CLIENT_PATH}`,\n `ln -sf ${MCP_CLIENT_PATH} ${MCP_CLIENT_BIN_SYMLINK}`,\n ];\n\n // Fold skill installation into this single init exec. The mounter returns a declarative\n // command + env + timeout; runs even when empty so its downloader can prune a reused sandbox.\n const skillInit = this.skillMounter?.getSandboxInit();\n if (skillInit) {\n initSteps.push(skillInit.command);\n }\n\n const script = initSteps.join(' && ');\n const result = await this.provider.exec({\n sandboxId: this.requiredSandboxInfo.sandbox_id,\n command: script,\n env: skillInit?.env,\n timeoutSeconds: skillInit?.timeoutSeconds,\n });\n ensureExecSuccess(result);\n this.logger.info(\n `Sandbox initialized: MCP client at ${MCP_CLIENT_PATH} (symlinked to ${MCP_CLIENT_BIN_SYMLINK}); skills dir ${SKILLS_DIR}`,\n );\n\n await this.writeGitCredentials();\n }\n\n /**\n * Lazily start Code Mode transport. Single-flight and idempotent inside the transport.\n * Skipped when code mode was never configured. Connect failures resolve to `undefined`\n * (no Code Mode env for that exec); mcp_client then errors at call time — no HTTPS fallback.\n */\n private async ensureCodeModeStarted(sandboxId: string): Promise<Record<string, string> | undefined> {\n const dispatcher = this.codeModeDispatcher;\n const transport = this.codeModeTransport;\n if (dispatcher === undefined || transport === undefined) {\n return undefined;\n }\n try {\n const { env } = await transport.start({\n codeModeDispatcher: dispatcher,\n sandboxId,\n requestTimeoutSeconds: this.requestTimeoutSeconds,\n });\n return env;\n } catch (e) {\n // Connect-level errors are not sticky: transport clears its session so a later exec may retry.\n this.logger.error('Code Mode transport start failed', {\n ...extractErrorLogFields(e),\n sandboxId,\n });\n return undefined;\n }\n }\n\n // Tears down per-Sandbox Code Mode resources. Dispatcher close first (in-flight requests get\n // source: transport), then transport.stop() — always safe whether start succeeded, failed, or never ran.\n async close(): Promise<void> {\n this.codeModeDispatcher?.close();\n this.codeModeDispatcher = undefined;\n const transport = this.codeModeTransport;\n this.codeModeTransport = undefined;\n if (transport === undefined) {\n return;\n }\n try {\n await transport.stop();\n } catch (e) {\n this.logger.warn('Error stopping Code Mode transport', extractErrorLogFields(e));\n }\n }\n}\n"],"mappings":";AAAA,SAAS,SAAS,mBAAmB;AACrC,OAAO,YAAY;AAEnB,SAAS,SAAS;AAClB,SAAS,0BAA0B;AACnC,SAAS,+BAA+B;AAExC,SAAS,0BAAgE;AACzE,SAAS,YAAY,oBAAoB;AACzC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAEP,SAAS,6BAA6B;AACtC,SAAS,0BAA0B;AAEnC,SAAS,gCAAgC;AACzC,SAAS,mBAAmB,mBAAyC;AACrE,SAAS,yBAAyB,oCAAoC;AACtE,SAAS,sBAAsB;AAE/B,SAAS,kBAAkB;AAQpB,IAAM,iCAAiC;AAI9C,IAAM,sCAAsC;AAGrC,SAAS,8BAA8B,QAA+D;AAC3G,QAAM,MAAM,OAAO,KAAK,OAAO,eAAe,OAAO,EAAE,SAAS,QAAQ;AACxE,SAAO;AAAA,IACL,uBAAuB,OAAO,UAAU;AAAA,IACxC,SAAS,OAAO,UAAU;AAAA,IAC1B,QAAQ,YAAY,GAAG,CAAC,kBAAkB,OAAO,UAAU;AAAA,IAC3D,aAAa,OAAO,UAAU;AAAA,IAC9B,WAAW,OAAO,UAAU;AAAA,EAC9B,EAAE,KAAK,MAAM;AACf;AAGA,SAAS,+BAA+B,oBAAmC,iBAAiC;AAC1G,MAAI,uBAAuB,MAAM;AAC/B,WAAO,SAAS,eAAe;AAAA,EACjC;AAEA,QAAM,MAAM,OAAO,KAAK,oBAAoB,OAAO,EAAE,SAAS,QAAQ;AACtE,SACE,uBAAuB,eAAe,eAC7B,GAAG,mBAAmB,eAAe,iBACjC,eAAe;AAEhC;AAMA,SAAS,4BAA4B,iBAAiD;AACpF,SAAO;AAAA,IACL,kBAAkB;AAAA,IAClB,kBAAkB;AAAA,IAClB,oBAAoB,gBAAgB,eAAe;AAAA,EACrD;AACF;AA2BO,IAAM,yBAAyB;AACtC,IAAM,2BAA2B;AACjC,IAAM,0BAA0B;AACzB,IAAM,2BAA2B;AACxC,IAAM,2BAA2B;AAOjC,IAAM,iBAAiB;AACvB,IAAM,kBAAkB,GAAG,cAAc;AACzC,IAAM,yBAAyB;AAW/B,SAAS,wBAAgD;AACvD,QAAM,UAAkC,CAAC;AACzC,cAAY,OAAO,QAAQ,OAAO,GAAG,OAAO;AAC5C,QAAM,MAA8B,CAAC;AACrC,MAAI,QAAQ,aAAa,GAAG;AAC1B,QAAI,iBAAiB,IAAI,QAAQ,aAAa;AAAA,EAChD;AACA,MAAI,QAAQ,YAAY,GAAG;AACzB,QAAI,gBAAgB,IAAI,QAAQ,YAAY;AAAA,EAC9C;AACA,SAAO;AACT;AAMA,SAAS,mBAAmB,QAKD;AACzB,QAAM,cAAc,OAAO,eAAe,CAAC;AAC3C,QAAM,uBAAuB,OAAO,KAAK,WAAW,EAAE,SAAS;AAC/D,SAAO;AAAA,IACL,GAAI,OAAO,gBAAgB,CAAC;AAAA,IAC5B,GAAI,OAAO,OAAO,CAAC;AAAA,IACnB,YAAY;AAAA,IACZ,GAAI,OAAO,KAAK,OAAO,UAAU,EAAE,SAAS,KAAK;AAAA,MAC/C,iBAAiB,OAAO,KAAK,KAAK,UAAU,OAAO,UAAU,CAAC,EAAE,SAAS,QAAQ;AAAA,IACnF;AAAA,IACA,GAAG;AAAA,IACH,GAAI,wBAAwB;AAAA;AAAA;AAAA,MAG1B,GAAG,sBAAsB;AAAA,IAC3B;AAAA;AAAA,IAEA,4BAA4B;AAAA,EAC9B;AACF;AAEA,IAAM,oBAAoB,EAAE,OAAO;AAAA,EACjC,QAAQ,EACL,OAAO,EACP;AAAA,IACC;AAAA,EACF;AAAA,EACF,SAAS,EAAE,OAAO,EAAE,SAAS,8CAA8C;AAAA,EAC3E,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS,0CAA0C;AAAA,EAC9E,KAAK,EAAE,OAAO,EAAE,OAAO,GAAG,EAAE,OAAO,CAAC,EAAE,SAAS,EAAE,SAAS,0CAA0C;AACtG,CAAC;AAED,IAAM,2BAA2B;AAAA;AAAA;AAAA;AAAA;AAAA;AAO1B,SAAS,yCAAiD;AAC/D,SAAO,oKAAoK,wBAAwB;AACrM;AAEO,IAAM,wBAAwB;AAG9B,IAAM,UAAN,MAAM,iBAAgB,aAAa;AAAA,EAC/B,OAAO;AAAA,EACP,cAAc;AAAA,EACL,cAAc;AAAA,EAChC,OAAgB,mBAAmB;AAAA,EAElB;AAAA,EACA;AAAA,EACA;AAAA,EACT;AAAA;AAAA,EAEA;AAAA,EACA;AAAA,EACA,mBAAwC,CAAC;AAAA,EAChC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACT;AAAA,EACS;AAAA,EACA;AAAA;AAAA,EAEA;AAAA,EACT;AAAA,EACA;AAAA,EAEA,QAAQ;AAAA,IACd,WAAW;AAAA,MACT,MAAM;AAAA,MACN,aAAa;AAAA,MACb,QAAQ;AAAA,MACR,SAAS,WAAS,KAAK,WAAW,KAAK;AAAA,IACzC,CAAC;AAAA,EACH;AAAA,EAEA,YAAY,SAAyB;AACnC,UAAM,EAAE,SAAS,QAAQ,QAAQ,CAAC;AAClC,SAAK,WAAW,QAAQ;AACxB,SAAK,oBAAoB,QAAQ;AACjC,SAAK,aAAa,QAAQ,eAAe,iBAAiB,KAAK;AAC/D,SAAK,eAAe,QAAQ;AAC5B,SAAK,sBAAsB,QAAQ,uBAAuB;AAC1D,UAAM,oBAAoB,QAAQ,sBAAsB,QAAQ;AAChE,SAAK,wBAAwB,KAAK,KAAK,oBAAoB,GAAI,IAAI;AACnE,SAAK,eAAe,QAAQ;AAI5B,SAAK,wBAAwB,OAAO,KAAK,eAAe,WAAW,OAAO,EAAE,SAAS,QAAQ;AAC7F,SAAK,SAAS,QAAQ,OAAO,MAAM,EAAE,QAAQ,UAAU,CAAC;AACxD,SAAK,gCAAgC,QAAQ,iCAAiC;AAE9E,QAAI,KAAK,mBAAmB;AAC1B,mCAA6B,KAAK,mBAAmB,KAAK,UAAU;AACpE,WAAK,sBAAsB,EAAE,YAAY,KAAK,kBAAkB;AAAA,IAClE;AAAA,EACF;AAAA,EAEQ,0BAAkE;AACxE,UAAM,MAA8C,CAAC;AACrD,eAAW,UAAU,KAAK,kBAAkB;AAC1C,YAAM,eAAe,OAAO,gCAAgC,KAAK,CAAC;AAClE,UAAI,OAAO,IAAI,IAAI,EAAE,eAAe,aAAa;AAAA,IACnD;AACA,WAAO;AAAA,EACT;AAAA,EAEU,WAAW;AACnB,WAAO,KAAK;AAAA,EACd;AAAA,EAEA,kBAAkB,SAAoC;AACpD,QAAI,CAAC,QAAQ,QAAQ;AACnB;AAAA,IACF;AACA,QAAI,KAAK,uBAAuB,UAAa,KAAK,sBAAsB,QAAW;AACjF,YAAM,IAAI,MAAM,kDAAkD;AAAA,IACpE;AACA,SAAK,mBAAmB;AACxB,SAAK,qBAAqB,IAAI,mBAAmB,EAAE,UAAU,SAAS,QAAQ,KAAK,OAAO,CAAC;AAC3F,SAAK,oBAAoB,KAAK,SAAS,wBAAwB;AAAA,EACjE;AAAA,EAEA,uBAA+B;AAC7B,WAAO,KAAK,MAAM,wBAAwB,KAAK,oBAAoB,CAAC,CAAC;AAAA,EACvE;AAAA;AAAA,EAGQ,sBAA8B;AACpC,QAAI,KAAK,wBAAwB,QAAW;AAC1C,YAAM,SAAS,IAAI,mBAAmB,QAAQ;AAC9C,WAAK,cAAc,YAAY,MAAM;AACrC,WAAK,sBAAsB,OAAO,MAAM;AAAA,IAC1C;AACA,WAAO,KAAK;AAAA,EACd;AAAA,EAEA,iBAAiB,SAAmC;AAClD,UAAM,uBAAuB,KAAK,SAAS,0BAA0B;AAErE,UAAM,sBAAsB,QAAQ,aAAa,SAAS;AAC1D,wBAAoB,WAAW,8EAA8E;AAC7G,wBAAoB,WAAW,6DAA6D;AAE5F,SAAK,mBAAmB,mBAAmB;AAC3C,SAAK,mBAAmB,mBAAmB;AAC3C,SAAK,gBAAgB,mBAAmB;AACxC,SAAK,wBAAwB,mBAAmB;AAEhD,QAAI,KAAK,qBAAqB;AAC5B,WAAK,uBAAuB,mBAAmB;AAAA,IACjD;AAEA,QAAI,sBAAsB;AACxB,0BAAoB,WAAW,oBAAoB;AAAA,IACrD;AAAA,EACF;AAAA,EAEQ,wBAAwB,SAAmC;AACjE,YAAQ;AAAA,MACN;AAAA,MACA,qCAAqC,SAAQ,gBAAgB;AAAA,IAC/D;AAAA,EACF;AAAA,EAEQ,mBAAmB,SAAmC;AAC5D,YAAQ;AAAA,MACN;AAAA,MACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAQF;AAAA,EACF;AAAA,EAEQ,uBAAuB,SAAmC;AAChE,YAAQ;AAAA,MACN;AAAA,MACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAgBF;AAAA,EACF;AAAA,EAEQ,mBAAmB,SAAmC;AAC5D,YAAQ,WAAW,KAAK,oBAAoB,CAAC;AAAA,EAC/C;AAAA,EAEQ,gBAAgB,SAAmC;AACzD,UAAM,eAAe,KAAK,iBAAiB,IAAI,OAAK,EAAE,IAAI,EAAE,KAAK,GAAG;AACpE,QAAI,CAAC,cAAc;AACjB;AAAA,IACF;AAEA,YAAQ;AAAA,MACN;AAAA,MACA;AAAA,sEACgE,sBAAsB,6CAA6C,YAAY;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,gCAOrH,eAAe,SAAS,wBAAwB,gBAAgB,2BAA2B;AAAA,oDACvE,kBAAkB;AAAA;AAAA;AAAA,uCAG/B,2BAA2B;AAAA;AAAA;AAAA;AAAA,6EAIW,wBAAwB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAmDjG;AAAA,EACF;AAAA,EAEA,IAAY,sBAAmC;AAC7C,QAAI,CAAC,KAAK,qBAAqB;AAC7B,YAAM,IAAI,MAAM,4BAA4B;AAAA,IAC9C;AACA,WAAO,KAAK;AAAA,EACd;AAAA,EAEA,MAAc,uBAGX;AACD,QAAI,KAAK,qBAAqB;AAC5B,aAAO,EAAE,aAAa,KAAK,qBAAqB,gBAAgB,OAAU;AAAA,IAC5E;AAIA,SAAK,2BAA2B,KAAK,SAClC,cAAc,EACd,KAAK,CAAC,EAAE,UAAU,MAAM;AACvB,mCAA6B,WAAW,KAAK,UAAU;AACvD,aAAO,EAAE,YAAY,UAAU;AAAA,IACjC,CAAC,EACA,MAAM,CAAC,MAAe;AACrB,WAAK,yBAAyB;AAC9B,YAAM;AAAA,IACR,CAAC;AAEH,UAAM,cAAc,MAAM,KAAK;AAI/B,UAAM,QAAQ,KAAK;AACnB,UAAM,QAAQ,UAAU;AACxB,SAAK,sBAAsB;AAC3B,WAAO,EAAE,aAAa,gBAAgB,QAAQ,cAAc,OAAU;AAAA,EACxE;AAAA,EAEA,MAAc,WAAW,OAAoD;AAC3E,UAAM,EAAE,aAAa,eAAe,IAAI,MAAM,KAAK,qBAAqB;AACxE,UAAM,qBAAqB,QAAQ,cAAc;AACjD,QAAI;AACF,YAAM,KAAK,yBAAyB;AAAA,IACtC,SAAS,GAAG;AACV,WAAK,OAAO,MAAM,iCAAiC,sBAAsB,CAAC,CAAC;AAC3E,YAAM,UAAU,aAAa,QAAQ,EAAE,UAAU;AACjD,aAAO,mBAAmB;AAAA,QACxB,MAAM,kCAAkC,OAAO;AAAA,QAC/C,SAAS;AAAA,QACT,WAAW,EAAE,gBAAgB,oBAAoB,YAAY;AAAA,MAC/D,CAAC;AAAA,IACH;AACA,UAAM,cAAc,MAAM,KAAK,sBAAsB,YAAY,UAAU;AAC3E,UAAM,eAAe,mBAAmB;AAAA,MACtC,KAAK,MAAM;AAAA,MACX,YAAY,KAAK,wBAAwB;AAAA,MACzC,cAAc,KAAK;AAAA,MACnB;AAAA,IACF,CAAC;AACD,UAAM,aACJ,KAAK,kCAAkC,OACnC,4BAA4B,KAAK,SAAS,sBAAsB,YAAY,UAAU,CAAC,IACvF,CAAC;AACP,UAAM,MAAM,EAAE,GAAG,cAAc,GAAG,WAAW;AAE7C,UAAM,SAAS,MAAM,KAAK,SAAS,KAAK;AAAA,MACtC,WAAW,YAAY;AAAA,MACvB,SAAS,MAAM;AAAA,MACf,KAAK,MAAM;AAAA,MACX;AAAA,IACF,CAAC;AAED,WAAO;AAAA,MACL,QAAQ;AAAA,QACN,SAAS,CAAC,EAAE,MAAM,QAAQ,MAAM,KAAK,UAAU,MAAM,EAAE,CAAC;AAAA,QACxD,SAAS,CAAC,OAAO;AAAA,MACnB;AAAA,MACA,gBAAgB;AAAA,MAChB,gBAAgB;AAAA,MAChB;AAAA,IACF;AAAA,EACF;AAAA,EAEA,MAAM,uBAA0F;AAC9F,UAAM,EAAE,eAAe,IAAI,MAAM,KAAK,qBAAqB;AAC3D,UAAM,MAAM,KAAK,SAAS,qBAAqB,KAAK,oBAAoB,UAAU,EAAE,QAAQ,QAAQ,EAAE;AACtG,WAAO,EAAE,KAAK,eAAe;AAAA,EAC/B;AAAA,EAEA,MAAM,WAAW,QAI0D;AACzE,UAAM,EAAE,eAAe,IAAI,MAAM,KAAK,qBAAqB;AAC3D,UAAM,KAAK,yBAAyB;AACpC,UAAM,EAAE,YAAY,UAAU,IAAI,KAAK;AACvC,UAAM,WAAW,OAAO,SAAS,QAAQ,QAAQ,EAAE;AACnD,UAAM,YAAY,OAAO,UAAU,QAAQ,QAAQ,EAAE;AACrD,UAAM,WAAW,GAAG,SAAS,IAAI,QAAQ;AACzC,UAAM,KAAK,SAAS,WAAW,EAAE,WAAW,YAAY,UAAU,SAAS,OAAO,QAAQ,CAAC;AAC3F,WAAO,EAAE,UAAU,eAAe;AAAA,EACpC;AAAA,EAEA,MAAM,eAAe,OAAwF;AAC3G,4BAAwB,MAAM,QAAQ;AACtC,UAAM,SAAS,MAAM,KAAK,WAAW;AAAA,MACnC,WAAW,SAAQ;AAAA,MACnB,UAAU,MAAM;AAAA,MAChB,SAAS,MAAM;AAAA,IACjB,CAAC;AACD,WAAO;AAAA,MACL,UAAU,OAAO;AAAA,MACjB,GAAI,OAAO,kBAAkB,EAAE,gBAAgB,OAAO,eAAe;AAAA,IACvE;AAAA,EACF;AAAA,EAEA,MAAM,cAAc,OAIW;AAC7B,4BAAwB,MAAM,QAAQ;AACtC,UAAM,EAAE,KAAK,gBAAgB,eAAe,IAAI,MAAM,KAAK,qBAAqB;AAChF,UAAM,SAAS,MAAM,KAAK,WAAW;AAAA,MACnC,WAAW;AAAA,MACX,UAAU,MAAM;AAAA,MAChB,SAAS,MAAM;AAAA,IACjB,CAAC;AACD,UAAM,iBAAiB,kBAAkB,OAAO;AAChD,WAAO;AAAA,MACL,UAAU,OAAO;AAAA,MACjB,GAAI,kBAAkB,EAAE,eAAe;AAAA,IACzC;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAc,2BAA0C;AACtD,SAAK,uBAAuB,KAAK,uBAAuB,EAAE,MAAM,CAAC,MAAe;AAC9E,WAAK,qBAAqB;AAC1B,YAAM;AAAA,IACR,CAAC;AACD,UAAM,KAAK;AAAA,EACb;AAAA,EAEA,MAAc,sBAAqC;AACjD,UAAM,YAAY,KAAK,oBAAoB;AAC3C,UAAM,kBAAkB,KAAK,SAAS,sBAAsB,SAAS;AACrE,UAAM,SAAS,MAAM,KAAK,SAAS,KAAK;AAAA,MACtC;AAAA,MACA,SAAS,+BAA+B,KAAK,+BAA+B,eAAe;AAAA,IAC7F,CAAC;AACD,sBAAkB,MAAM;AAAA,EAC1B;AAAA,EAEA,MAAc,yBAAwC;AACpD,UAAM,oBAAoB,KAAK,SAAS,qBAAqB,KAAK,oBAAoB,UAAU;AAEhG,SAAK,OAAO,KAAK,uEAAuE;AAExF,UAAM,YAAY;AAAA,MAChB,YAAY,cAAc,IAAI,SAAQ,gBAAgB,IAAI,iBAAiB,IAAI,UAAU;AAAA,MACzF,SAAS,eAAe;AAAA,MACxB,SAAS,KAAK,qBAAqB,mBAAmB,eAAe;AAAA;AAAA,MAErE,cAAc,eAAe;AAAA,MAC7B,UAAU,eAAe,IAAI,sBAAsB;AAAA,IACrD;AAIA,UAAM,YAAY,KAAK,cAAc,eAAe;AACpD,QAAI,WAAW;AACb,gBAAU,KAAK,UAAU,OAAO;AAAA,IAClC;AAEA,UAAM,SAAS,UAAU,KAAK,MAAM;AACpC,UAAM,SAAS,MAAM,KAAK,SAAS,KAAK;AAAA,MACtC,WAAW,KAAK,oBAAoB;AAAA,MACpC,SAAS;AAAA,MACT,KAAK,WAAW;AAAA,MAChB,gBAAgB,WAAW;AAAA,IAC7B,CAAC;AACD,sBAAkB,MAAM;AACxB,SAAK,OAAO;AAAA,MACV,sCAAsC,eAAe,kBAAkB,sBAAsB,iBAAiB,UAAU;AAAA,IAC1H;AAEA,UAAM,KAAK,oBAAoB;AAAA,EACjC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAc,sBAAsB,WAAgE;AAClG,UAAM,aAAa,KAAK;AACxB,UAAM,YAAY,KAAK;AACvB,QAAI,eAAe,UAAa,cAAc,QAAW;AACvD,aAAO;AAAA,IACT;AACA,QAAI;AACF,YAAM,EAAE,IAAI,IAAI,MAAM,UAAU,MAAM;AAAA,QACpC,oBAAoB;AAAA,QACpB;AAAA,QACA,uBAAuB,KAAK;AAAA,MAC9B,CAAC;AACD,aAAO;AAAA,IACT,SAAS,GAAG;AAEV,WAAK,OAAO,MAAM,oCAAoC;AAAA,QACpD,GAAG,sBAAsB,CAAC;AAAA,QAC1B;AAAA,MACF,CAAC;AACD,aAAO;AAAA,IACT;AAAA,EACF;AAAA;AAAA;AAAA,EAIA,MAAM,QAAuB;AAC3B,SAAK,oBAAoB,MAAM;AAC/B,SAAK,qBAAqB;AAC1B,UAAM,YAAY,KAAK;AACvB,SAAK,oBAAoB;AACzB,QAAI,cAAc,QAAW;AAC3B;AAAA,IACF;AACA,QAAI;AACF,YAAM,UAAU,KAAK;AAAA,IACvB,SAAS,GAAG;AACV,WAAK,OAAO,KAAK,sCAAsC,sBAAsB,CAAC,CAAC;AAAA,IACjF;AAAA,EACF;AACF;","names":[]}
1
+ {"version":3,"sources":["../../../src/core/sandbox/Sandbox.ts"],"sourcesContent":["import { context, propagation } from '@opentelemetry/api';\nimport dedent from 'dedent';\nimport type { Logger } from 'winston';\nimport { z } from 'zod';\nimport { InstructionBuilder } from '../InstructionBuilder';\nimport { estimateTokensForString } from '../llm/usage';\nimport type { MappedMCPTool } from '../mcp/convertMCPServers';\nimport { toolResultResponse, type CallToolResponse, type IToolSet } from '../mcp/IMCPServer';\nimport { defineTool, LocalToolMCP } from '../mcp/LocalToolMCP';\nimport {\n DEFERRED_TOOLS_SERVER_ID,\n GET_TOOL_INFO_NAME,\n GET_TOOL_OUTPUT_SCHEMA_NAME,\n LIST_TOOLS_NAME,\n} from '../runtime/DeferredTool';\nimport type { AgentTracing } from '../tracing/AgentTracing';\nimport { extractErrorLogFields } from '../util/errorLogFields';\nimport { CodeModeDispatcher } from './codeMode/CodeModeDispatcher';\nimport type { CodeModeTransport } from './codeMode/CodeModeTransport';\nimport { SANDBOX_FILE_UPLOADS_DIR } from './constants';\nimport { ensureExecSuccess, shellEscape, type SandboxProvider } from './provider/Provider';\nimport { validateNoPathTraversal, validateSandboxOwnedByTenant } from './SandboxErrors';\nimport { sandboxScripts } from './sandboxScripts.gen';\n// Import submodules, not the ./skills barrel, to avoid a cycle (the mounters import from Sandbox).\nimport { SKILLS_DIR } from './skills/constants';\nimport type { ISkillMounter } from './skills/ISkillMounter';\n\nexport interface SandboxInfo {\n sandbox_id: string;\n}\n\n// Downloader/setup scripts can run longer than a normal exec.\nexport const SKILL_DOWNLOAD_TIMEOUT_SECONDS = 180;\n\n// Keeps the sandbox-side NATS wait longer than the host's own MCP deadline, so the host\n// surfaces the real MCP error instead of the bridge client giving up first.\nconst NATS_REQUEST_TIMEOUT_BUFFER_SECONDS = 30;\n\n// Write a script (base64-encoded, never interpolated raw) to a path and run it. Skill mounters reuse it.\nexport function buildWriteAndRunScriptCommand(params: { scriptPath: string; scriptContent: string }): string {\n const b64 = Buffer.from(params.scriptContent, 'utf-8').toString('base64');\n return [\n `mkdir -p \"$(dirname ${params.scriptPath})\"`,\n `rm -f ${params.scriptPath}`,\n `echo ${shellEscape(b64)} | base64 -d > ${params.scriptPath}`,\n `chmod a-w ${params.scriptPath}`,\n `python3 ${params.scriptPath}`,\n ].join(' && ');\n}\n\n/** Write or clear the git credential-store file at an absolute path (no global git config mutation). */\nfunction buildSyncGitCredentialsCommand(credentialsContent: string | null, credentialsPath: string): string {\n if (credentialsContent === null) {\n return `rm -f ${credentialsPath}`;\n }\n // Base64 avoids interpolating credentials directly into the shell command.\n const b64 = Buffer.from(credentialsContent, 'utf-8').toString('base64');\n return (\n `mkdir -p \"$(dirname ${credentialsPath})\" && ` +\n `echo '${b64}' | base64 -d > ${credentialsPath} && ` +\n `chmod 600 ${credentialsPath}`\n );\n}\n\n/**\n * Process-scoped credential.helper via GIT_CONFIG_* (avoids shared ~/.gitconfig).\n * Required for TFY sandbox where many logical sandboxes share one pod/HOME.\n */\nfunction buildGitCredentialHelperEnv(credentialsPath: string): Record<string, string> {\n return {\n GIT_CONFIG_COUNT: '1',\n GIT_CONFIG_KEY_0: 'credential.helper',\n GIT_CONFIG_VALUE_0: `store --file ${credentialsPath}`,\n };\n}\n\nexport interface SandboxStoredFile {\n filePath: string;\n sandboxCreated?: SandboxInfo | undefined;\n}\n\nexport interface SandboxOptions {\n provider: SandboxProvider;\n existingSandboxId?: string | undefined;\n skillMounter?: ISkillMounter | undefined;\n fileDownloadEnabled?: boolean | undefined;\n /** Pre-resolved credential-store file content (null = clear / no git auth). */\n resolvedGitCredentialsContent?: string | null | undefined;\n /**\n * Blocks destructive tools in code mode so they go through the approval flow\n * instead. Must be `true` — approvals are always enabled (the kill switch is gone).\n */\n blockDestructiveToolsInCodeMode: true;\n /** Used to derive the Code Mode NATS wait as request + connect. */\n mcpRequestTimeoutMs: number;\n mcpConnectTimeoutMs: number;\n tenantName: string;\n tracing: AgentTracing;\n logger: Logger;\n}\n\nexport const SANDBOX_EXEC_TOOL_NAME = 'exec';\nconst SANDBOX_MCP_REMINDER_TAG = 'sandbox-mcp-code-mode';\nconst SANDBOX_FILE_OUTPUT_TAG = 'sandbox-file-output';\nexport const SANDBOX_SCHEMA_INFER_TAG = 'sandbox-schema-infer';\nconst SANDBOX_FILE_UPLOADS_TAG = 'sandbox-file-uploads';\n\n// Stable directory inside the sandbox where the MCP client library is uploaded.\n// MCP client upload dir. Two lookup channels:\n// - Python imports → PYTHONPATH points here (set by injectMCPClientEnv).\n// - Shell `mcp-client` → symlinked into MCP_CLIENT_BIN_SYMLINK (on default PATH);\n// basename drops `.py` so the agent doesn't reflexively prefix with `python`.\nconst MCP_CLIENT_DIR = '/opt/tfy/mcp-client';\nconst MCP_CLIENT_PATH = `${MCP_CLIENT_DIR}/mcp_client.py`;\nconst MCP_CLIENT_BIN_SYMLINK = '/usr/local/bin/mcp-client';\n\n// Per-server config sent to mcp_client.py via TFY_MCP_SERVERS.\ninterface SandboxMcpServerConfig {\n allowed_tools: string[];\n}\n\n// Snapshots the active W3C trace context (`traceparent` + optional `tracestate`) into env\n// vars consumed by mcp_client.py. Read with TFY_TRACEPARENT / TFY_TRACESTATE on the sandbox\n// side and attached as NATS message headers; the bridge extracts them on each message so the\n// resulting MCP spans nest under the originating Sandbox: exec span.\nfunction injectTraceContextEnv(): Record<string, string> {\n const carrier: Record<string, string> = {};\n propagation.inject(context.active(), carrier);\n const env: Record<string, string> = {};\n if (carrier['traceparent']) {\n env['TFY_TRACEPARENT'] = carrier['traceparent'];\n }\n if (carrier['tracestate']) {\n env['TFY_TRACESTATE'] = carrier['tracestate'];\n }\n return env;\n}\n\n/**\n * Injects environment variables required for mcp_client.py into sandbox_exec arguments.\n * Transport-specific vars (NATS URL/prefix/timeout) come from `codeModeEnv` via transport.start().\n */\nfunction injectMCPClientEnv(params: {\n env?: Record<string, string> | undefined;\n mcpServers: Record<string, SandboxMcpServerConfig>;\n codeModeEnv?: Record<string, string> | undefined;\n}): Record<string, string> {\n const codeModeEnv = params.codeModeEnv ?? {};\n const hasCodeModeTransport = Object.keys(codeModeEnv).length > 0;\n return {\n ...(params.env ?? {}),\n PYTHONPATH: MCP_CLIENT_DIR,\n ...(Object.keys(params.mcpServers).length > 0 && {\n TFY_MCP_SERVERS: Buffer.from(JSON.stringify(params.mcpServers)).toString('base64'),\n }),\n ...codeModeEnv,\n ...(hasCodeModeTransport && {\n // W3C trace context captured per-exec so each Code Mode request carries the originating\n // Sandbox: exec span as parent.\n ...injectTraceContextEnv(),\n }),\n // Approvals are always enabled: destructive tools cannot run in code mode.\n TFY_ENABLE_AGENT_APPROVALS: 'true',\n };\n}\n\nconst sandboxExecSchema = z.object({\n intent: z\n .string()\n .describe(\n 'A brief, user-facing explanation of the purpose of this command. Avoid technical jargon and code details.',\n ),\n command: z.string().describe('The shell command to execute in the sandbox.'),\n cwd: z.string().optional().describe('Working directory for command execution.'),\n env: z.record(z.string(), z.string()).optional().describe('Additional environment variables to set.'),\n});\n\nconst SANDBOX_EXEC_DESCRIPTION = `Execute shell commands in a persistent sandbox environment.\nThe sandbox persists across calls within the same session, allowing stateful operations.\nPre-installed: Python 3.13, pydantic, git, curl, helm, jq, ripgrep, genson.\nIMPORTANT: The Agent must never list, print, or expose environment variables as they contain sensitive credentials.\nIMPORTANT: Never read or expose any git credential files or ~/.git-credentials.\n`;\n\nexport function createSandboxLargeToolResponseGuidance(): string {\n return `For use cases where the Agent needs a subset of fields from the MCP response or needs deterministic processing, write code with the provided MCP client and use <${SANDBOX_MCP_REMINDER_TAG}>.`;\n}\n\nexport const SANDBOX_MCP_SERVER_ID = 'sandbox';\ntype SandboxExecInput = z.infer<typeof sandboxExecSchema>;\n\nexport class Sandbox extends LocalToolMCP {\n readonly name = SANDBOX_MCP_SERVER_ID;\n readonly displayName = 'Sandbox';\n override readonly description = 'Persistent sandbox environment for code execution';\n static readonly FILE_UPLOADS_DIR = SANDBOX_FILE_UPLOADS_DIR;\n\n private readonly provider: SandboxProvider;\n private readonly existingSandboxId?: string | undefined;\n private readonly tenantName: string;\n private existingSandboxInfo: SandboxInfo | undefined;\n // Cached promise to prevent concurrent sub-agents from creating duplicate sandboxes.\n private sandboxCreationPromise?: Promise<SandboxInfo> | undefined;\n private sandboxInitPromise?: Promise<void> | undefined;\n private codeExecToolSets: readonly IToolSet[] = [];\n private readonly fileDownloadEnabled: boolean;\n private readonly requestTimeoutSeconds: number;\n private readonly skillMounter?: ISkillMounter | undefined;\n private cachedSkillsSection?: string | undefined;\n private readonly mcpClientScriptBase64: string;\n private readonly logger: Logger;\n // Pre-resolved credential-store file content (null = clear / no git auth).\n private readonly resolvedGitCredentialsContent: string | null;\n private codeModeDispatcher: CodeModeDispatcher | undefined;\n private codeModeTransport: CodeModeTransport | undefined;\n\n private tools = [\n defineTool({\n name: SANDBOX_EXEC_TOOL_NAME,\n description: SANDBOX_EXEC_DESCRIPTION,\n schema: sandboxExecSchema,\n handler: input => this.handleExec(input),\n }),\n ];\n\n constructor(options: SandboxOptions) {\n super({ tracing: options.tracing });\n this.provider = options.provider;\n this.existingSandboxId = options.existingSandboxId;\n this.tenantName = options.tenantName;\n this.skillMounter = options.skillMounter;\n this.fileDownloadEnabled = options.fileDownloadEnabled ?? false;\n const mcpBoundTimeoutMs = options.mcpRequestTimeoutMs + options.mcpConnectTimeoutMs;\n this.requestTimeoutSeconds = Math.ceil(mcpBoundTimeoutMs / 1000) + NATS_REQUEST_TIMEOUT_BUFFER_SECONDS;\n // Scripts are internal to Sandbox: the upload paths, env contract, and prompt\n // text are all hardcoded here, so injecting different content was never a\n // real extension point. Consumers don't see or provide them.\n this.mcpClientScriptBase64 = Buffer.from(sandboxScripts.mcpClient, 'utf-8').toString('base64');\n this.logger = options.logger.child({ module: 'Sandbox' });\n this.resolvedGitCredentialsContent = options.resolvedGitCredentialsContent ?? null;\n\n if (this.existingSandboxId) {\n validateSandboxOwnedByTenant(this.existingSandboxId, this.tenantName);\n this.existingSandboxInfo = { sandbox_id: this.existingSandboxId };\n }\n }\n\n private buildMcpServersEnvelope(): Record<string, SandboxMcpServerConfig> {\n const out: Record<string, SandboxMcpServerConfig> = {};\n for (const server of this.codeExecToolSets) {\n const allowedTools = server.getAllowedToolNamesForSandbox?.() ?? [];\n out[server.name] = { allowed_tools: allowedTools };\n }\n return out;\n }\n\n protected getTools() {\n return this.tools;\n }\n\n configureCodeMode(servers: readonly IToolSet[]): void {\n if (!servers.length) {\n return;\n }\n if (this.codeModeDispatcher !== undefined || this.codeModeTransport !== undefined) {\n throw new Error('Code Mode is already configured for this Sandbox');\n }\n this.codeExecToolSets = servers;\n this.codeModeDispatcher = new CodeModeDispatcher({ toolSets: servers, logger: this.logger });\n this.codeModeTransport = this.provider.createCodeModeTransport();\n }\n\n estimateSkillsTokens(): number {\n return Math.round(estimateTokensForString(this.renderSkillsSection()));\n }\n\n // Single source for the <skills> section, so the prompt and the token estimate can't drift.\n private renderSkillsSection(): string {\n if (this.cachedSkillsSection === undefined) {\n const skills = new InstructionBuilder('skills');\n this.skillMounter?.instruction(skills);\n this.cachedSkillsSection = skills.build();\n }\n return this.cachedSkillsSection;\n }\n\n buildInstruction(builder: InstructionBuilder): void {\n const providerInstructions = this.provider.getAdditionalInstructions();\n\n const sandboxInstructions = builder.beginSection('sandbox');\n sandboxInstructions.addContent('The Agent has access to a persistent sandbox environment for executing code.');\n sandboxInstructions.addContent('The Agent must NOT read or modify any git credential files.');\n\n this.buildSchemaSection(sandboxInstructions);\n this.buildSkillsSection(sandboxInstructions);\n this.buildMCPSection(sandboxInstructions);\n this.buildFileUploadsSection(sandboxInstructions);\n\n if (this.fileDownloadEnabled) {\n this.buildFileOutputSection(sandboxInstructions);\n }\n\n if (providerInstructions) {\n sandboxInstructions.addContent(providerInstructions);\n }\n }\n\n private buildFileUploadsSection(builder: InstructionBuilder): void {\n builder.addSection(\n SANDBOX_FILE_UPLOADS_TAG,\n `User-uploaded files are placed in ${Sandbox.FILE_UPLOADS_DIR}/. The Agent must not modify files in this directory. Always create a copy at a different location and work on the copy.`,\n );\n }\n\n private buildSchemaSection(builder: InstructionBuilder): void {\n builder.addSection(\n SANDBOX_SCHEMA_INFER_TAG,\n dedent`\n To infer the schema of a file, the Agent MUST execute:\n head -c 200 file_path && echo \"==\" && genson file_path\n This serves two purpose:\n 1. The first 200 characters of the file is printed. Do NOT print more than 200 characters.\n 2. genson infers and returns JSON schema if the file is JSON.\n Even if the file is not JSON, (1) helps to take the next step in less number of turns\n `,\n );\n }\n\n private buildFileOutputSection(builder: InstructionBuilder): void {\n builder.addSection(\n SANDBOX_FILE_OUTPUT_TAG,\n dedent`\n ## File outputs\n\n The user does not have direct access to the sandbox filesystem. When the Agent wants to output a file to the user, it MUST:\n 1. Ensure that the file is present in the sandbox.\n 2. Then emit a fenced sandbox_artifacts block referencing the file.\n\n \\`\\`\\`sandbox_artifacts\n [Human-readable label](/absolute/path/to/file.ext)\n \\`\\`\\`\n\n For large outputs or content that can be generated through code, prefer writing to a file and emitting the sandbox_artifacts block.\n\n Rules:\n - Use absolute paths, not ~/ or relative paths.\n - One link per line. Markdown link syntax: [label](path).`,\n );\n }\n\n private buildSkillsSection(builder: InstructionBuilder): void {\n builder.addContent(this.renderSkillsSection());\n }\n\n private buildMCPSection(builder: InstructionBuilder): void {\n const toolSetNames = this.codeExecToolSets.map(m => m.name).join(',');\n if (!toolSetNames) {\n return;\n }\n\n builder.addSection(\n SANDBOX_MCP_REMINDER_TAG,\n dedent`\n The following MCP servers are accessible for Code Mode from ${SANDBOX_EXEC_TOOL_NAME} via the pre-installed mcp_client module: ${toolSetNames}\n Tool and functions are same.\n Tool and MCP tools are same.\n\n Use \\`from mcp_client import call_tool\\` in code, or \\`mcp-client\\` in shell — both work from any directory with no setup.\n\n For deferred MCP servers, the Agent MUST:\n 1. Discover tools via ${LIST_TOOLS_NAME} from ${DEFERRED_TOOLS_SERVER_ID}. Never call ${GET_TOOL_OUTPUT_SCHEMA_NAME}.\n 2. Discover inputSchema, outputSchema via ${GET_TOOL_INFO_NAME} for the selected tools.\n\n For non-deferred MCP servers, the Agent MUST:\n 1. Discover outputSchema via ${GET_TOOL_OUTPUT_SCHEMA_NAME} for the selected tools.\n\n For both deferred and non-deferred if outputSchema is absent,\n the Agent MUST say: \"Given the MCP Server does not define an outputSchema let me figure it out first before entering Code Mode.\"\n mcp-client call-tool {server} {tool} '{\"param\":\"value\"}' > file && ${SANDBOX_SCHEMA_INFER_TAG}.\n the Agent MUST execute a single command for above.\n If the Agent does not get the outputSchema, the Agent will encounters scenarios like:\n Agent: \"Let me fix the code - it seems the x might be a string rather than an object in some cases:\"\n The Agent MUST not face the above scenario.\n \n Only after the Agent has the above details, it can enter Code Mode.\n Agent: Now I know the inputSchema and outputSchema of tool_a, let me enter Code Mode.\n\n CLI: mcp-client call-tool {server} {tool_a} '{\"param\":\"value\"}'\n # The output of the above command MUST be passed through tools like jq or head -c\n # or should be written to a file. Goal is to reduce content that the Agent prints.\n\n Code: from mcp_client import call_tool\n async def main():\n result = await call_tool(\"{server}\", \"{tool_a}\", body={\"param1\": \"value1\"})\n # In most cases, it is safe to assume response is a dict.\n # To know the dict keys, the Agent still needs to figure out the outputSchema.\n # The Agent MUST write the code to a file first before executing it.\n\n Code Mode MUST be used in following scenarios:\n\n 1. Reduce structured tool output by executing logic on top of it. Never present raw output.\n Agent: Now I know the inputSchema and outputSchema of tool_a, let me enter Code Mode.\n\n from mcp_client import call_tool\n from collections import Counter\n result = await call_tool(\"{server}\", \"{tool_a}\", body={\"param1\": \"value1\"})\n print(Counter(r[\"a\"] for r in result[\"b\"] if r[\"c\"])) # Here we need outputSchema to know a, b and c exists.\n\n 2. Piping result of one tool call to another tool request. Never present raw output.\n Agent: Now I know the inputSchema and outputSchema of tool1, let me enter Code Mode.\n\n from mcp_client import call_tool\n result1 = await call_tool(\"{server}\", \"{tool1}\", body={\"param1\": \"value1\"})\n result2 = await call_tool(\"{server}\", \"{tool2}\", body={\"param2\": result1[\"value1\"]}) # Here we need outputSchema to know value1 exists.\n print(result2) # Note in this case, we do not need outputSchema of result2 as we are not using any fields.\n\n The Agent MUST NOT use Code Mode just to print the full raw response of a tool. The Agent MUST call the tool directly in this case, there are other mechanisms in place to ensure\n large tool call outputs are not loaded in context.\n \n The Agent must not use Code Mode in the following patterns:\n from mcp_client import call_tool\n import json\n result = await call_tool(\"{server}\", \"{tool}\", body={\"param1\": \"value1\"})\n print(result) # Never do this. Call tool without Code Mode.\n print(json.dumps(result, ...)) # Never do this. Call tool without Code Mode.\n print(result[\"a\"][:100]) # Never do this. Call tool without Code Mode.\n\n mcp-client call-tool {server} {tool} '{\"param\":\"value\"}' > data && cat data # Never do this. Call tool without Code Mode.\n `,\n );\n }\n\n private get requiredSandboxInfo(): SandboxInfo {\n if (!this.existingSandboxInfo) {\n throw new Error('Sandbox info not available');\n }\n return this.existingSandboxInfo;\n }\n\n private async ensureSandboxCreated(): Promise<{\n sandboxInfo: SandboxInfo;\n sandboxCreated: SandboxInfo | undefined;\n }> {\n if (this.existingSandboxInfo) {\n return { sandboxInfo: this.existingSandboxInfo, sandboxCreated: undefined };\n }\n // Provider returns camelCase `{ sandboxId }`; rename to the snake_case\n // `SandboxInfo` shape used everywhere downstream (Redis JSON, wire event,\n // in-memory state).\n this.sandboxCreationPromise ??= this.provider\n .createSandbox()\n .then(({ sandboxId }) => {\n validateSandboxOwnedByTenant(sandboxId, this.tenantName);\n return { sandbox_id: sandboxId };\n })\n .catch((e: unknown) => {\n this.sandboxCreationPromise = undefined;\n throw e;\n });\n\n const sandboxInfo = await this.sandboxCreationPromise;\n // Concurrent callers may both await the same creation promise; only the first\n // should report sandboxCreated. Read via a local to avoid control-flow narrowing\n // that treats existingSandboxInfo as always undefined after the early return above.\n const prior = this.existingSandboxInfo as SandboxInfo | undefined;\n const isNew = prior === undefined;\n this.existingSandboxInfo = sandboxInfo;\n return { sandboxInfo, sandboxCreated: isNew ? sandboxInfo : undefined };\n }\n\n private async handleExec(input: SandboxExecInput): Promise<CallToolResponse> {\n const { sandboxInfo, sandboxCreated } = await this.ensureSandboxCreated();\n const sandboxCreatedFlag = Boolean(sandboxCreated);\n try {\n await this.ensureSandboxInitialized();\n } catch (e) {\n this.logger.error('Sandbox initialization failed', extractErrorLogFields(e));\n const message = e instanceof Error ? e.message : 'Sandbox initialization failed';\n return toolResultResponse({\n text: `Sandbox initialization failed: ${message}`,\n isError: true,\n overrides: { sandboxCreated: sandboxCreatedFlag, sandboxInfo },\n });\n }\n const codeModeEnv = await this.ensureCodeModeStarted(sandboxInfo.sandbox_id);\n const mcpClientEnv = injectMCPClientEnv({\n env: input.env,\n mcpServers: this.buildMcpServersEnvelope(),\n codeModeEnv,\n });\n const gitAuthEnv =\n this.resolvedGitCredentialsContent !== null\n ? buildGitCredentialHelperEnv(this.provider.getGitCredentialsPath(sandboxInfo.sandbox_id))\n : {};\n const env = { ...mcpClientEnv, ...gitAuthEnv };\n\n const result = await this.provider.exec({\n sandboxId: sandboxInfo.sandbox_id,\n command: input.command,\n cwd: input.cwd,\n env,\n });\n\n return {\n result: {\n content: [{ type: 'text', text: JSON.stringify(result) }],\n isError: !result.success,\n },\n wasInitialized: undefined,\n sandboxCreated: sandboxCreatedFlag,\n sandboxInfo,\n };\n }\n\n async getToolResultDumpDir(): Promise<{ dir: string; sandboxCreated: SandboxInfo | undefined }> {\n const { sandboxCreated } = await this.ensureSandboxCreated();\n const dir = this.provider.getToolResultDumpDir(this.requiredSandboxInfo.sandbox_id).replace(/\\/+$/, '');\n return { dir, sandboxCreated };\n }\n\n async uploadFile(params: {\n targetDir: string;\n fileName: string;\n content: Buffer;\n }): Promise<{ filePath: string; sandboxCreated: SandboxInfo | undefined }> {\n const { sandboxCreated } = await this.ensureSandboxCreated();\n await this.ensureSandboxInitialized();\n const { sandbox_id: sandboxId } = this.requiredSandboxInfo;\n const fileName = params.fileName.replace(/^\\/+/, '');\n const targetDir = params.targetDir.replace(/\\/+$/, '');\n const filePath = `${targetDir}/${fileName}`;\n await this.provider.uploadFile({ sandboxId, remotePath: filePath, content: params.content });\n return { filePath, sandboxCreated };\n }\n\n async uploadUserFile(input: { fileName: string; content: Buffer; mime: string }): Promise<SandboxStoredFile> {\n validateNoPathTraversal(input.fileName);\n const result = await this.uploadFile({\n targetDir: Sandbox.FILE_UPLOADS_DIR,\n fileName: input.fileName,\n content: input.content,\n });\n return {\n filePath: result.filePath,\n ...(result.sandboxCreated && { sandboxCreated: result.sandboxCreated }),\n };\n }\n\n async writeArtifact(input: {\n fileName: string;\n content: Buffer;\n sourceTool?: MappedMCPTool | undefined;\n }): Promise<SandboxStoredFile> {\n validateNoPathTraversal(input.fileName);\n const { dir, sandboxCreated: dumpDirCreated } = await this.getToolResultDumpDir();\n const result = await this.uploadFile({\n targetDir: dir,\n fileName: input.fileName,\n content: input.content,\n });\n const sandboxCreated = dumpDirCreated ?? result.sandboxCreated;\n return {\n filePath: result.filePath,\n ...(sandboxCreated && { sandboxCreated }),\n };\n }\n\n /**\n * Upload the MCP client Python script to the sandbox on the first tool call.\n * The sandbox filesystem is persistent within a session, so this only needs to run once.\n */\n private async ensureSandboxInitialized(): Promise<void> {\n this.sandboxInitPromise ??= this.initSandboxEnvironment().catch((e: unknown) => {\n this.sandboxInitPromise = undefined;\n throw e;\n });\n await this.sandboxInitPromise;\n }\n\n private async writeGitCredentials(): Promise<void> {\n const sandboxId = this.requiredSandboxInfo.sandbox_id;\n const credentialsPath = this.provider.getGitCredentialsPath(sandboxId);\n const result = await this.provider.exec({\n sandboxId,\n command: buildSyncGitCredentialsCommand(this.resolvedGitCredentialsContent, credentialsPath),\n });\n ensureExecSuccess(result);\n }\n\n private async initSandboxEnvironment(): Promise<void> {\n const toolResultDumpDir = this.provider.getToolResultDumpDir(this.requiredSandboxInfo.sandbox_id);\n\n this.logger.info('Uploading MCP client script and preparing skills directory in sandbox');\n\n const initSteps = [\n `mkdir -p ${MCP_CLIENT_DIR} ${Sandbox.FILE_UPLOADS_DIR} ${toolResultDumpDir} ${SKILLS_DIR}`,\n `rm -f ${MCP_CLIENT_PATH}`,\n `echo '${this.mcpClientScriptBase64}' | base64 -d > ${MCP_CLIENT_PATH}`,\n // Make executable + symlink onto PATH so `mcp-client` runs by name.\n `chmod 0555 ${MCP_CLIENT_PATH}`,\n `ln -sf ${MCP_CLIENT_PATH} ${MCP_CLIENT_BIN_SYMLINK}`,\n ];\n\n // Fold skill installation into this single init exec. The mounter returns a declarative\n // command + env + timeout; runs even when empty so its downloader can prune a reused sandbox.\n const skillInit = this.skillMounter?.getSandboxInit();\n if (skillInit) {\n initSteps.push(skillInit.command);\n }\n\n const script = initSteps.join(' && ');\n const result = await this.provider.exec({\n sandboxId: this.requiredSandboxInfo.sandbox_id,\n command: script,\n env: skillInit?.env,\n timeoutSeconds: skillInit?.timeoutSeconds,\n });\n ensureExecSuccess(result);\n this.logger.info(\n `Sandbox initialized: MCP client at ${MCP_CLIENT_PATH} (symlinked to ${MCP_CLIENT_BIN_SYMLINK}); skills dir ${SKILLS_DIR}`,\n );\n\n await this.writeGitCredentials();\n }\n\n /**\n * Lazily start Code Mode transport. Single-flight and idempotent inside the transport.\n * Skipped when code mode was never configured. Connect failures resolve to `undefined`\n * (no Code Mode env for that exec); mcp_client then errors at call time — no HTTPS fallback.\n */\n private async ensureCodeModeStarted(sandboxId: string): Promise<Record<string, string> | undefined> {\n const dispatcher = this.codeModeDispatcher;\n const transport = this.codeModeTransport;\n if (dispatcher === undefined || transport === undefined) {\n return undefined;\n }\n try {\n const { env } = await transport.start({\n codeModeDispatcher: dispatcher,\n sandboxId,\n requestTimeoutSeconds: this.requestTimeoutSeconds,\n });\n return env;\n } catch (e) {\n // Connect-level errors are not sticky: transport clears its session so a later exec may retry.\n this.logger.error('Code Mode transport start failed', {\n ...extractErrorLogFields(e),\n sandboxId,\n });\n return undefined;\n }\n }\n\n // Tears down per-Sandbox Code Mode resources. Dispatcher close first (in-flight requests get\n // source: transport), then transport.stop() — always safe whether start succeeded, failed, or never ran.\n async close(): Promise<void> {\n this.codeModeDispatcher?.close();\n this.codeModeDispatcher = undefined;\n const transport = this.codeModeTransport;\n this.codeModeTransport = undefined;\n if (transport === undefined) {\n return;\n }\n try {\n await transport.stop();\n } catch (e) {\n this.logger.warn('Error stopping Code Mode transport', extractErrorLogFields(e));\n }\n }\n}\n"],"mappings":"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","names":[]}
@@ -1,5 +1,5 @@
1
1
  export declare const sandboxScripts: {
2
- readonly mcpClient: "#!/usr/bin/env python3\n\n# /// script\n# dependencies = [\"pydantic==2.12.5\", \"nats-py==2.15.0\"]\n# ///\n\nimport os\nimport sys\nimport json\nimport time\nimport base64\nimport asyncio\nimport logging\nimport argparse\nfrom pathlib import Path\nfrom typing import Any, TypedDict\n\nfrom pydantic import BaseModel, ValidationError\n\nfrom mcp.types import CallToolResult, TextContent, Tool\n\nlogger = logging.getLogger(__name__)\n\n_NATS_REQUEST_MAX_ATTEMPTS = 3\n_NATS_RETRY_BACKOFF_MS = 250\n\n_nats_url = os.environ.get(\"TFY_NATS_URL\")\n_nats_subject_prefix = os.environ.get(\"TFY_NATS_SUBJECT_PREFIX\")\n_NATS_SUBJECT = f\"{_nats_subject_prefix}.mcp\" if _nats_url and _nats_subject_prefix else None\nif bool(_nats_url) != bool(_nats_subject_prefix):\n raise RuntimeError(\n \"TFY_NATS_URL and TFY_NATS_SUBJECT_PREFIX must be set together; \"\n f\"got TFY_NATS_URL={'set' if _nats_url else 'unset'}, \"\n f\"TFY_NATS_SUBJECT_PREFIX={'set' if _nats_subject_prefix else 'unset'}\"\n ) from None\n\n_nats_trace_headers: dict[str, str] = {}\nif _traceparent := os.environ.get(\"TFY_TRACEPARENT\"):\n _nats_trace_headers[\"traceparent\"] = _traceparent\nif _tracestate := os.environ.get(\"TFY_TRACESTATE\"):\n _nats_trace_headers[\"tracestate\"] = _tracestate\n\n\nclass _ToolsCache(BaseModel):\n fetched_at: float\n tools: list[Tool]\n\n\nclass _BridgeReply(BaseModel):\n \"\"\"NATS bridge reply envelope. `result` is left untyped — operation-specific callers\n (Tool / CallToolResult) validate the payload; this model only validates the envelope.\"\"\"\n\n ok: bool\n result: Any = None\n error: str = \"unknown error\"\n source: str = \"internal\"\n\n\nclass _ServerConfig(TypedDict):\n allowed_tools: list[str]\n\n\n_TOOLS_CACHE_TTL_SECONDS = 600\n\n_inflight_list_tools: dict[str, asyncio.Task[list[Tool]]] = {}\n\n_raw_servers = os.environ.get(\"TFY_MCP_SERVERS\")\n_servers_map: dict[str, _ServerConfig] = json.loads(base64.b64decode(_raw_servers).decode()) if _raw_servers else {}\n# Approvals are always enabled (Sandbox always injects TFY_ENABLE_AGENT_APPROVALS=true).\n_enable_agent_approvals = os.environ.get(\"TFY_ENABLE_AGENT_APPROVALS\", \"true\").lower() == \"true\"\n\n\ndef _check_tool_allowed(server: str, tool_name: str) -> None:\n server_config = _servers_map.get(server)\n if server_config is None:\n raise RuntimeError(f\"Access denied: MCP server '{server}' is not available for this agent\") from None\n server_tools = server_config.get(\"allowed_tools\") or []\n if len(server_tools) > 0 and tool_name not in server_tools:\n raise RuntimeError(f\"Access denied: tool '{tool_name}' is not enabled on server '{server}'\") from None\n\n\ndef _cache_path(server: str) -> Path:\n return Path(__file__).parent / f\"{server}.tools.json\"\n\n\ndef _read_tools_cache(server: str) -> _ToolsCache | None:\n p = _cache_path(server)\n if not p.exists():\n return None\n try:\n cache = _ToolsCache.model_validate_json(p.read_text(encoding=\"utf-8\"))\n except Exception:\n logger.exception(\"_ToolsCache.model_validate_json\")\n p.unlink(missing_ok=True)\n return None\n if time.time() - cache.fetched_at > _TOOLS_CACHE_TTL_SECONDS:\n p.unlink(missing_ok=True)\n return None\n return cache\n\n\ndef _write_tools_cache(server: str, tools: list[Tool]) -> None:\n try:\n cache = _ToolsCache(fetched_at=time.time(), tools=tools)\n _cache_path(server).write_text(cache.model_dump_json(), encoding=\"utf-8\")\n except Exception:\n logger.exception(\"_write_tools_cache\")\n\n\ndef _require_nats_subject() -> str:\n if not _NATS_SUBJECT or not _nats_url:\n raise RuntimeError(\n \"NATS bridge setup failed; NATS bridge is required for MCP from Code Mode. \"\n \"Try again.\"\n ) from None\n return _NATS_SUBJECT\n\n\nasync def _fetch_tools(server: str) -> list[Tool]:\n result = await _nats_request(_require_nats_subject(), {\"op\": \"list_tools\", \"server\": server})\n if not isinstance(result, dict):\n raise RuntimeError(f\"NATS list_tools '{server}' returned unexpected shape: {result!r}\") from None\n return [Tool.model_validate(t) for t in result.get(\"tools\", [])]\n\n\nasync def _fetch_and_cache_tools(server: str) -> list[Tool]:\n tools = await _fetch_tools(server)\n _write_tools_cache(server, tools)\n return tools\n\n\nasync def _nats_request(subject: str, payload: dict[str, Any]) -> Any:\n \"\"\"Send one request over a short-lived bridge connection, unwrap { ok, result|error }.\n\n Connection is per-call (closed in `finally`) to avoid leaking nats-py's aiohttp session.\n Connect and request are retried in separate loops so a successful connection isn't torn\n down and rebuilt on a request-level retry. Retries only failures that prove the request\n never reached a handler (connect failure, NoRespondersError); timeouts and post-delivery\n failures are not retried so a non-idempotent tool can't double-execute.\n \"\"\"\n import nats\n from nats.errors import NoRespondersError\n\n data = json.dumps(payload).encode()\n request_timeout = float(os.environ[\"TFY_CM_REQUEST_TIMEOUT_SECONDS\"])\n\n # Phase 1: establish a connection, retrying only connect failures.\n nc = None\n last_connect_error = \"unknown error\"\n for attempt in range(1, _NATS_REQUEST_MAX_ATTEMPTS + 1):\n if attempt > 1:\n await asyncio.sleep(_NATS_RETRY_BACKOFF_MS / 1000)\n try:\n nc = await nats.connect(_nats_url)\n break\n except Exception as e:\n last_connect_error = f\"connect to NATS at {_nats_url} failed: {e}\"\n if nc is None:\n raise RuntimeError(\n f\"NATS connect failed for '{subject}' after {_NATS_REQUEST_MAX_ATTEMPTS} attempts: {last_connect_error}\"\n ) from None\n\n # Phase 2: request over the established connection, retrying only NoRespondersError.\n try:\n last_transport_error = \"unknown error\"\n for attempt in range(1, _NATS_REQUEST_MAX_ATTEMPTS + 1):\n if attempt > 1:\n await asyncio.sleep(_NATS_RETRY_BACKOFF_MS / 1000)\n try:\n msg = await nc.request(\n subject,\n data,\n timeout=request_timeout,\n headers=_nats_trace_headers or None,\n )\n except NoRespondersError as e:\n # Server confirms no subscriber — never delivered, safe to retry.\n last_transport_error = f\"no NATS responder for '{subject}': {e}\"\n continue\n except Exception as e:\n # Timeout / other: the tool may already have run, so we must not retry.\n raise RuntimeError(f\"NATS request '{subject}' failed: {e}\") from None\n try:\n reply = _BridgeReply.model_validate_json(msg.data.decode())\n except ValidationError as e:\n raise RuntimeError(f\"NATS reply on '{subject}' is malformed: {e}\") from None\n if not reply.ok:\n # A well-formed reply means the transport worked; `source` says who's at fault.\n if reply.source == \"caller\":\n raise RuntimeError(f\"Invalid MCP request on '{subject}': {reply.error}\") from None\n if reply.source == \"transport\":\n raise RuntimeError(f\"Code Mode transport error on '{subject}': {reply.error}\") from None\n raise RuntimeError(f\"Internal MCP error on '{subject}': {reply.error}\") from None\n return reply.result\n raise RuntimeError(\n f\"NATS bridge has no responder for '{subject}' after {_NATS_REQUEST_MAX_ATTEMPTS} attempts: {last_transport_error}\"\n ) from None\n finally:\n try:\n await nc.close()\n except Exception:\n logger.debug(\"Error closing NATS connection\", exc_info=True)\n\n\nasync def _get_tools(server: str) -> list[Tool]:\n cache = _read_tools_cache(server)\n if cache is not None:\n return cache.tools\n task = _inflight_list_tools.get(server)\n if task is None:\n task = asyncio.create_task(_fetch_and_cache_tools(server))\n _inflight_list_tools[server] = task\n task.add_done_callback(lambda _t: _inflight_list_tools.pop(server, None))\n return await task\n\n\nasync def _get_tool(server: str, tool_name: str) -> Tool | None:\n for t in await _get_tools(server):\n if t.name == tool_name:\n return t\n return None\n\n\ndef _is_destructive(tool: Tool) -> bool:\n annotations = tool.annotations\n if annotations is None:\n return False\n return bool(annotations.destructiveHint) or (not annotations.readOnlyHint and annotations.readOnlyHint is not None)\n\nasync def _ensure_non_destructive(server: str, tool_name: str) -> None:\n tool = await _get_tool(server, tool_name)\n if tool is None:\n raise RuntimeError(f\"Tool '{tool_name}' not found on MCP server '{server}'\") from None\n if _is_destructive(tool):\n raise RuntimeError(\n f\"Tool '{tool_name}' on MCP server '{server}' is destructive and cannot be called in Code Mode; \"\n f\"call it directly so it can go through the user approval flow\"\n ) from None\n\n\ndef _project_call_tool_result(server: str, tool: str, result: CallToolResult) -> Any:\n \"\"\"Project an MCP-wire CallToolResult into the user-facing Python value.\"\"\"\n if result.isError:\n text_parts = [c.text for c in result.content if isinstance(c, TextContent) and c.text]\n msg = \"; \".join(text_parts) if text_parts else \"tool returned an error\"\n raise RuntimeError(f\"MCP tool error (server={server}, tool={tool}): {msg}\") from None\n\n if result.structuredContent is not None:\n return result.structuredContent\n\n # Fallback to content blocks (tools without outputSchema / plain text responses)\n if len(result.content) == 1:\n first = result.content[0]\n if isinstance(first, TextContent) and first.text:\n try:\n return json.loads(first.text)\n except Exception:\n pass\n if result.content:\n return result.content\n return None\n\n\nasync def call_tool(server: str, tool: str, body: dict[str, Any]) -> Any:\n _check_tool_allowed(server, tool)\n if _enable_agent_approvals:\n await _ensure_non_destructive(server, tool)\n\n raw = await _nats_request(\n _require_nats_subject(),\n {\"op\": \"call_tool\", \"server\": server, \"tool\": tool, \"arguments\": body},\n )\n try:\n result = CallToolResult.model_validate(raw)\n except Exception as e:\n raise RuntimeError(f\"NATS call_tool reply for '{server}/{tool}' is malformed: {e}\") from None\n\n return _project_call_tool_result(server, tool, result)\n\n\n_USAGE = \"mcp_client.py call-tool <server> <tool> <args-json>\"\n\n\ndef _build_arg_parser() -> argparse.ArgumentParser:\n parser = argparse.ArgumentParser(prog=\"mcp_client.py\", usage=_USAGE)\n sub = parser.add_subparsers(dest=\"cmd\", required=True)\n\n call_tool_p = sub.add_parser(\"call-tool\", help=\"Invoke an MCP tool\")\n call_tool_p.add_argument(\"server\")\n call_tool_p.add_argument(\"tool\")\n # Parsing JSON at the argparse boundary surfaces malformed input as a clean\n # argparse error (with usage) instead of an opaque traceback from inside the handler.\n call_tool_p.add_argument(\"args_json\", metavar=\"args-json\", type=json.loads)\n\n return parser\n\n\nasync def _main() -> None:\n args = _build_arg_parser().parse_args()\n try:\n if args.cmd == \"call-tool\":\n result = await call_tool(args.server, args.tool, args.args_json)\n print(json.dumps(result, default=str))\n except RuntimeError as e:\n sys.exit(str(e))\n\n\nif __name__ == \"__main__\":\n asyncio.run(_main())\n";
2
+ readonly mcpClient: "#!/usr/bin/env python3\n\n# /// script\n# dependencies = [\"mcp==1.29.0\", \"pydantic==2.12.5\", \"nats-py==2.15.0\"]\n# ///\n\nimport os\nimport sys\nimport json\nimport time\nimport base64\nimport asyncio\nimport logging\nimport argparse\nfrom pathlib import Path\nfrom typing import Any, TypedDict\n\nfrom pydantic import BaseModel, ValidationError\n\nfrom mcp.types import CallToolResult, TextContent, Tool\n\nlogger = logging.getLogger(__name__)\n\n_NATS_REQUEST_MAX_ATTEMPTS = 3\n_NATS_RETRY_BACKOFF_MS = 250\n\n_nats_url = os.environ.get(\"TFY_NATS_URL\")\n_nats_subject_prefix = os.environ.get(\"TFY_NATS_SUBJECT_PREFIX\")\n_NATS_SUBJECT = f\"{_nats_subject_prefix}.mcp\" if _nats_url and _nats_subject_prefix else None\nif bool(_nats_url) != bool(_nats_subject_prefix):\n raise RuntimeError(\n \"TFY_NATS_URL and TFY_NATS_SUBJECT_PREFIX must be set together; \"\n f\"got TFY_NATS_URL={'set' if _nats_url else 'unset'}, \"\n f\"TFY_NATS_SUBJECT_PREFIX={'set' if _nats_subject_prefix else 'unset'}\"\n ) from None\n\n_nats_trace_headers: dict[str, str] = {}\nif _traceparent := os.environ.get(\"TFY_TRACEPARENT\"):\n _nats_trace_headers[\"traceparent\"] = _traceparent\nif _tracestate := os.environ.get(\"TFY_TRACESTATE\"):\n _nats_trace_headers[\"tracestate\"] = _tracestate\n\n\nclass _ToolsCache(BaseModel):\n fetched_at: float\n tools: list[Tool]\n\n\nclass _BridgeReply(BaseModel):\n \"\"\"NATS bridge reply envelope. `result` is left untyped — operation-specific callers\n (Tool / CallToolResult) validate the payload; this model only validates the envelope.\"\"\"\n\n ok: bool\n result: Any = None\n error: str = \"unknown error\"\n source: str = \"internal\"\n\n\nclass _ServerConfig(TypedDict):\n allowed_tools: list[str]\n\n\n_TOOLS_CACHE_TTL_SECONDS = 600\n\n_inflight_list_tools: dict[str, asyncio.Task[list[Tool]]] = {}\n\n_raw_servers = os.environ.get(\"TFY_MCP_SERVERS\")\n_servers_map: dict[str, _ServerConfig] = json.loads(base64.b64decode(_raw_servers).decode()) if _raw_servers else {}\n# Approvals are always enabled (Sandbox always injects TFY_ENABLE_AGENT_APPROVALS=true).\n_enable_agent_approvals = os.environ.get(\"TFY_ENABLE_AGENT_APPROVALS\", \"true\").lower() == \"true\"\n\n\ndef _check_tool_allowed(server: str, tool_name: str) -> None:\n server_config = _servers_map.get(server)\n if server_config is None:\n raise RuntimeError(f\"Access denied: MCP server '{server}' is not available for this agent\") from None\n server_tools = server_config.get(\"allowed_tools\") or []\n if len(server_tools) > 0 and tool_name not in server_tools:\n raise RuntimeError(f\"Access denied: tool '{tool_name}' is not enabled on server '{server}'\") from None\n\n\ndef _cache_path(server: str) -> Path:\n return Path(__file__).parent / f\"{server}.tools.json\"\n\n\ndef _read_tools_cache(server: str) -> _ToolsCache | None:\n p = _cache_path(server)\n if not p.exists():\n return None\n try:\n cache = _ToolsCache.model_validate_json(p.read_text(encoding=\"utf-8\"))\n except Exception:\n logger.exception(\"_ToolsCache.model_validate_json\")\n p.unlink(missing_ok=True)\n return None\n if time.time() - cache.fetched_at > _TOOLS_CACHE_TTL_SECONDS:\n p.unlink(missing_ok=True)\n return None\n return cache\n\n\ndef _write_tools_cache(server: str, tools: list[Tool]) -> None:\n try:\n cache = _ToolsCache(fetched_at=time.time(), tools=tools)\n _cache_path(server).write_text(cache.model_dump_json(), encoding=\"utf-8\")\n except Exception:\n logger.exception(\"_write_tools_cache\")\n\n\ndef _require_nats_subject() -> str:\n if not _NATS_SUBJECT or not _nats_url:\n raise RuntimeError(\n \"NATS bridge setup failed; NATS bridge is required for MCP from Code Mode. \"\n \"Try again.\"\n ) from None\n return _NATS_SUBJECT\n\n\nasync def _fetch_tools(server: str) -> list[Tool]:\n result = await _nats_request(_require_nats_subject(), {\"op\": \"list_tools\", \"server\": server})\n if not isinstance(result, dict):\n raise RuntimeError(f\"NATS list_tools '{server}' returned unexpected shape: {result!r}\") from None\n return [Tool.model_validate(t) for t in result.get(\"tools\", [])]\n\n\nasync def _fetch_and_cache_tools(server: str) -> list[Tool]:\n tools = await _fetch_tools(server)\n _write_tools_cache(server, tools)\n return tools\n\n\nasync def _nats_request(subject: str, payload: dict[str, Any]) -> Any:\n \"\"\"Send one request over a short-lived bridge connection, unwrap { ok, result|error }.\n\n Connection is per-call (closed in `finally`) to avoid leaking nats-py's aiohttp session.\n Connect and request are retried in separate loops so a successful connection isn't torn\n down and rebuilt on a request-level retry. Retries only failures that prove the request\n never reached a handler (connect failure, NoRespondersError); timeouts and post-delivery\n failures are not retried so a non-idempotent tool can't double-execute.\n \"\"\"\n import nats\n from nats.errors import NoRespondersError\n\n data = json.dumps(payload).encode()\n request_timeout = float(os.environ[\"TFY_CM_REQUEST_TIMEOUT_SECONDS\"])\n\n # Phase 1: establish a connection, retrying only connect failures.\n nc = None\n last_connect_error = \"unknown error\"\n for attempt in range(1, _NATS_REQUEST_MAX_ATTEMPTS + 1):\n if attempt > 1:\n await asyncio.sleep(_NATS_RETRY_BACKOFF_MS / 1000)\n try:\n nc = await nats.connect(_nats_url)\n break\n except Exception as e:\n last_connect_error = f\"connect to NATS at {_nats_url} failed: {e}\"\n if nc is None:\n raise RuntimeError(\n f\"NATS connect failed for '{subject}' after {_NATS_REQUEST_MAX_ATTEMPTS} attempts: {last_connect_error}\"\n ) from None\n\n # Phase 2: request over the established connection, retrying only NoRespondersError.\n try:\n last_transport_error = \"unknown error\"\n for attempt in range(1, _NATS_REQUEST_MAX_ATTEMPTS + 1):\n if attempt > 1:\n await asyncio.sleep(_NATS_RETRY_BACKOFF_MS / 1000)\n try:\n msg = await nc.request(\n subject,\n data,\n timeout=request_timeout,\n headers=_nats_trace_headers or None,\n )\n except NoRespondersError as e:\n # Server confirms no subscriber — never delivered, safe to retry.\n last_transport_error = f\"no NATS responder for '{subject}': {e}\"\n continue\n except Exception as e:\n # Timeout / other: the tool may already have run, so we must not retry.\n raise RuntimeError(f\"NATS request '{subject}' failed: {e}\") from None\n try:\n reply = _BridgeReply.model_validate_json(msg.data.decode())\n except ValidationError as e:\n raise RuntimeError(f\"NATS reply on '{subject}' is malformed: {e}\") from None\n if not reply.ok:\n # A well-formed reply means the transport worked; `source` says who's at fault.\n if reply.source == \"caller\":\n raise RuntimeError(f\"Invalid MCP request on '{subject}': {reply.error}\") from None\n if reply.source == \"transport\":\n raise RuntimeError(f\"Code Mode transport error on '{subject}': {reply.error}\") from None\n raise RuntimeError(f\"Internal MCP error on '{subject}': {reply.error}\") from None\n return reply.result\n raise RuntimeError(\n f\"NATS bridge has no responder for '{subject}' after {_NATS_REQUEST_MAX_ATTEMPTS} attempts: {last_transport_error}\"\n ) from None\n finally:\n try:\n await nc.close()\n except Exception:\n logger.debug(\"Error closing NATS connection\", exc_info=True)\n\n\nasync def _get_tools(server: str) -> list[Tool]:\n cache = _read_tools_cache(server)\n if cache is not None:\n return cache.tools\n task = _inflight_list_tools.get(server)\n if task is None:\n task = asyncio.create_task(_fetch_and_cache_tools(server))\n _inflight_list_tools[server] = task\n task.add_done_callback(lambda _t: _inflight_list_tools.pop(server, None))\n return await task\n\n\nasync def _get_tool(server: str, tool_name: str) -> Tool | None:\n for t in await _get_tools(server):\n if t.name == tool_name:\n return t\n return None\n\n\ndef _is_destructive(tool: Tool) -> bool:\n annotations = tool.annotations\n if annotations is None:\n return False\n return bool(annotations.destructiveHint) or (not annotations.readOnlyHint and annotations.readOnlyHint is not None)\n\nasync def _ensure_non_destructive(server: str, tool_name: str) -> None:\n tool = await _get_tool(server, tool_name)\n if tool is None:\n raise RuntimeError(f\"Tool '{tool_name}' not found on MCP server '{server}'\") from None\n if _is_destructive(tool):\n raise RuntimeError(\n f\"Tool '{tool_name}' on MCP server '{server}' is destructive and cannot be called in Code Mode; \"\n f\"call it directly so it can go through the user approval flow\"\n ) from None\n\n\ndef _project_call_tool_result(server: str, tool: str, result: CallToolResult) -> Any:\n \"\"\"Project an MCP-wire CallToolResult into the user-facing Python value.\"\"\"\n if result.isError:\n text_parts = [c.text for c in result.content if isinstance(c, TextContent) and c.text]\n msg = \"; \".join(text_parts) if text_parts else \"tool returned an error\"\n raise RuntimeError(f\"MCP tool error (server={server}, tool={tool}): {msg}\") from None\n\n if result.structuredContent is not None:\n return result.structuredContent\n\n # Fallback to content blocks (tools without outputSchema / plain text responses)\n if len(result.content) == 1:\n first = result.content[0]\n if isinstance(first, TextContent) and first.text:\n try:\n return json.loads(first.text)\n except Exception:\n pass\n if result.content:\n return result.content\n return None\n\n\nasync def call_tool(server: str, tool: str, body: dict[str, Any]) -> Any:\n _check_tool_allowed(server, tool)\n if _enable_agent_approvals:\n await _ensure_non_destructive(server, tool)\n\n raw = await _nats_request(\n _require_nats_subject(),\n {\"op\": \"call_tool\", \"server\": server, \"tool\": tool, \"arguments\": body},\n )\n try:\n result = CallToolResult.model_validate(raw)\n except Exception as e:\n raise RuntimeError(f\"NATS call_tool reply for '{server}/{tool}' is malformed: {e}\") from None\n\n return _project_call_tool_result(server, tool, result)\n\n\n_USAGE = \"mcp_client.py call-tool <server> <tool> <args-json>\"\n\n\ndef _build_arg_parser() -> argparse.ArgumentParser:\n parser = argparse.ArgumentParser(prog=\"mcp_client.py\", usage=_USAGE)\n sub = parser.add_subparsers(dest=\"cmd\", required=True)\n\n call_tool_p = sub.add_parser(\"call-tool\", help=\"Invoke an MCP tool\")\n call_tool_p.add_argument(\"server\")\n call_tool_p.add_argument(\"tool\")\n # Parsing JSON at the argparse boundary surfaces malformed input as a clean\n # argparse error (with usage) instead of an opaque traceback from inside the handler.\n call_tool_p.add_argument(\"args_json\", metavar=\"args-json\", type=json.loads)\n\n return parser\n\n\nasync def _main() -> None:\n args = _build_arg_parser().parse_args()\n try:\n if args.cmd == \"call-tool\":\n result = await call_tool(args.server, args.tool, args.args_json)\n print(json.dumps(result, default=str))\n except RuntimeError as e:\n sys.exit(str(e))\n\n\nif __name__ == \"__main__\":\n asyncio.run(_main())\n";
3
3
  readonly gitDownloader: "#!/usr/bin/env python3\n\n# /// script\n# dependencies = [\"pydantic==2.12.5\"]\n# ///\n\n\"\"\"Materialize git skill directories into TFY_SKILLS_DIR (default /opt/tfy/skills). Requires `git`.\n\nEach git skill (AGENT_GIT_SKILLS) is materialized with a blob-filtered sparse `git` clone that\nfetches only the requested subdir at a resolved object id, keyed by name. Mount `ref` values\n(branch, tag, or full object id) are resolved here via `git ls-remote` — the host never spawns\ngit. A single on-disk state file records the installed object id + subdir per name so a skill\nwhose tip hasn't moved is skipped instead of re-cloning every run, and skills no longer desired\nare pruned from disk. A sparse clone is used instead of a full repo tarball because its cost is\n~constant in the subdir size rather than the whole-repo size, which is dramatically faster for a\nsmall skill living in a large monorepo.\n\nOptional env:\n - AGENT_GIT_SKILLS (base64-encoded JSON list of {name, url, path, ref}; empty\n clears git downloads). `ref` is a branch, tag, or full object id.\n - TFY_SKILLS_DIR: override the skills directory (default /opt/tfy/skills).\n\"\"\"\n\nfrom __future__ import annotations\n\nimport base64\nimport json\nimport os\nimport re\nimport shutil\nimport subprocess\nimport sys\nimport tempfile\nfrom pathlib import Path\n\nfrom pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator\n\nDEFAULT_SKILLS_DIR = \"/opt/tfy/skills\"\nSTATE_FILE_NAME = \".tfy-skill-downloader-state.json\"\n# Per-git-invocation wall-clock cap so a hung/slow fetch can't stall sandbox init indefinitely.\nGIT_CLONE_TIMEOUT_SECONDS = 120\n# Cap the installed skill size so a huge repo-root skill can't fill the persistent skills dir. The\n# sparse clone already bounds a subdir skill to its subdir; this also guards the whole-repo (root\n# subdir) case where checkout hydrates every file.\nGIT_SKILL_MAX_BYTES = 200 * 1024 * 1024 # 200MB installed\n# Resolved object id used for fetch + skip state (sha1 / sha256).\nOBJECT_ID_RE = re.compile(r\"^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$\")\n# Mount ref (branch/tag/SHA) charset — must match host AgentSpec SkillMount.ref validation.\n# Validated before the value is passed to `git ls-remote` / used as a fetch arg fallback.\nGIT_REF_RE = re.compile(r\"^[A-Za-z0-9._\\-/]+$\")\nSKILLS_ROOT = Path(os.environ.get(\"TFY_SKILLS_DIR\", DEFAULT_SKILLS_DIR))\nSTATE_PATH = SKILLS_ROOT / STATE_FILE_NAME\n\n\nclass GitSkillError(Exception):\n \"\"\"Recoverable per-repo/per-skill git failure. Raised (not sys.exit) so a single bad repo or\n subdirectory doesn't abort the remaining repository groups (best-effort failure isolation).\"\"\"\n\n\ndef skill_dir(name: str) -> Path | None:\n \"\"\"Resolve a skill's on-disk directory. This is the ONLY place a skill name becomes a path, so\n the safety check here is what guarantees that no name — freshly requested or read back from the\n (possibly corrupt) state file — can escape SKILLS_ROOT during delete/copy.\n\n Returns None for an unsafe name (empty, absolute, containing a path separator, \".\" / \"..\", or the\n reserved state-file name) so each caller can skip it per its own failure contract instead of\n touching an out-of-tree path. The state-file name is reserved because a skill whose name equals it\n would resolve to STATE_PATH and let a skill install clobber the reconciliation state.\n \"\"\"\n if not name or name in (\".\", \"..\") or \"/\" in name or \"\\\\\" in name or name == STATE_FILE_NAME:\n return None\n return SKILLS_ROOT / name\n\n\nclass GitSkill(BaseModel):\n \"\"\"A git skill to materialize: `name`, `url`, `path`, and mount `ref` (branch, tag, or object id).\n This script resolves `ref` via `git ls-remote` in the sandbox, then sparse-fetches the resolved\n object id. The url and ref are format-validated here (defense in depth) so an unexpected value\n can't be interpreted as a git option or reach a non-public host.\"\"\"\n\n model_config = ConfigDict(extra=\"ignore\")\n\n name: str\n url: str\n path: str = \"\"\n # Branch, tag, or full object id from the agent_spec mount (resolved in-sandbox before fetch).\n ref: str\n\n @field_validator(\"url\")\n @classmethod\n def _validate_url(cls, v: str) -> str:\n # Only the two public hosts the gateway supports. Anchoring on the \"https://<host>/\" prefix\n # blocks non-https schemes (file://, ext::, ssh) and any other host, so this URL can't be\n # turned into an SSRF/local-file/command vector once it reaches `git`.\n if not (v.startswith(\"https://github.com/\") or v.startswith(\"https://gitlab.com/\")):\n raise ValueError(f\"git skill url must be a github.com/gitlab.com https URL: {v!r}\")\n return v\n\n @field_validator(\"ref\")\n @classmethod\n def _validate_ref(cls, v: str) -> str:\n if not v or \"..\" in v.split(\"/\") or not GIT_REF_RE.match(v):\n raise ValueError(f\"git skill ref is invalid: {v!r}\")\n if v.replace(\"/\", \"\") == \"\":\n raise ValueError(f\"git skill ref must not consist only of slashes: {v!r}\")\n return v\n\n @field_validator(\"path\")\n @classmethod\n def _validate_path(cls, v: str) -> str:\n # Relative subpath within the repo. Reject absolute paths and \"..\" segments so it can't\n # escape the repo root. Leading/trailing slashes are stripped (repo root == \"\"). A leading\n # \"-\" is safe because subdirs are fed to `git sparse-checkout set` via stdin, never as args.\n if v.startswith(\"/\"):\n raise ValueError(f\"git skill path must be relative: {v!r}\")\n stripped = v.strip(\"/\")\n if stripped and \"..\" in stripped.split(\"/\"):\n raise ValueError(f\"git skill path must not contain '..': {v!r}\")\n return stripped\n\n\nclass DownloadedGitSkill(BaseModel):\n \"\"\"A git skill recorded on disk: its dir name, the object id it was installed at, and the repo\n path it was installed from. The ref lets a later run skip re-downloading when the requested\n pin is unchanged; the path is part of the identity so that changing the path at the same\n pin still triggers a re-download instead of serving the previously-installed layout.\"\"\"\n\n model_config = ConfigDict(extra=\"ignore\")\n\n name: str\n # Empty when migrated from a pre-pin-tracking state file — treated as \"unknown\", forcing a\n # one-time re-download while still keeping the name tracked for pruning.\n ref: str = \"\"\n # Repo path the skill was installed from (repo root == \"\"). Empty on entries written before\n # path tracking; a mismatch against the requested path forces a one-time re-download.\n path: str = \"\"\n\n\nclass SkillDownloaderState(BaseModel):\n \"\"\"Persisted next to skills; tracks what was downloaded so undesired skills can be pruned.\"\"\"\n\n model_config = ConfigDict(extra=\"ignore\")\n\n downloaded_git_skills: list[DownloadedGitSkill] = Field(\n default_factory=list,\n description=\"Git skills successfully downloaded (name + installed object id).\",\n )\n\n\ndef _migrate_downloaded_git_skills(entries: list[object]) -> list[dict[str, object]]:\n \"\"\"Normalize state entries: rename legacy `commit_sha` → `ref` and `subdir` → `path`.\"\"\"\n migrated: list[dict[str, object]] = []\n for item in entries:\n if not isinstance(item, dict):\n continue\n entry = dict(item)\n if \"ref\" not in entry and isinstance(entry.get(\"commit_sha\"), str):\n entry[\"ref\"] = entry[\"commit_sha\"]\n entry.pop(\"commit_sha\", None)\n if \"path\" not in entry and isinstance(entry.get(\"subdir\"), str):\n entry[\"path\"] = entry[\"subdir\"]\n entry.pop(\"subdir\", None)\n migrated.append(entry)\n return migrated\n\n\ndef load_state() -> SkillDownloaderState:\n if not STATE_PATH.is_file():\n return SkillDownloaderState()\n try:\n raw = json.loads(STATE_PATH.read_text(encoding=\"utf-8\"))\n except (OSError, ValueError):\n return SkillDownloaderState()\n if not isinstance(raw, dict):\n return SkillDownloaderState()\n # Migrate a pre-pin-tracking state file: its `downloaded_git_names` (list of names, no pin)\n # becomes entries with an empty ref. That keeps the names tracked so undesired skills are\n # still pruned, and forces a one-time re-download (empty != any requested pin). Skipped when\n # the new field is already present so we never clobber real pins.\n if \"downloaded_git_skills\" not in raw and isinstance(raw.get(\"downloaded_git_names\"), list):\n raw[\"downloaded_git_skills\"] = [\n {\"name\": n, \"ref\": \"\"} for n in raw[\"downloaded_git_names\"] if isinstance(n, str)\n ]\n if isinstance(raw.get(\"downloaded_git_skills\"), list):\n raw[\"downloaded_git_skills\"] = _migrate_downloaded_git_skills(raw[\"downloaded_git_skills\"])\n try:\n return SkillDownloaderState.model_validate(raw)\n except ValidationError:\n return SkillDownloaderState()\n\n\ndef save_state(state: SkillDownloaderState) -> None:\n STATE_PATH.parent.mkdir(parents=True, exist_ok=True)\n tmp = STATE_PATH.with_name(STATE_PATH.name + \".tmp\")\n tmp.write_text(state.model_dump_json(indent=2), encoding=\"utf-8\")\n os.replace(tmp, STATE_PATH)\n\n\ndef _rmtree(path: Path, ignore_errors: bool = True) -> None:\n try:\n shutil.rmtree(path, ignore_errors=ignore_errors)\n except OSError:\n pass\n\n\ndef _delete_skill_dir_by_name(name: str, label: str) -> None:\n \"\"\"Fail-closed prune of a no-longer-desired skill's directory: abort the whole run if removal\n fails so we never silently leave a stale skill on disk. skill_dir() returns None for a\n corrupt/tampered name, so rmtree never runs on an out-of-tree path.\"\"\"\n dir_ = skill_dir(name)\n if dir_ is None or not dir_.is_dir():\n return\n try:\n shutil.rmtree(dir_)\n except OSError as e:\n sys.exit(f\"Could not remove skill directory {dir_} ({label}): {e}\")\n\n\ndef git_skills_from_env(raw: str | None) -> list[GitSkill]:\n \"\"\"Empty or missing env means \"no desired git skills\" (prune all git downloads).\"\"\"\n if raw is None or not str(raw).strip():\n return []\n try:\n data = json.loads(base64.b64decode(raw))\n except (ValueError, json.JSONDecodeError) as e:\n sys.exit(f\"AGENT_GIT_SKILLS is not valid base64-encoded JSON: {e}\")\n if not isinstance(data, list):\n sys.exit(\"AGENT_GIT_SKILLS must be a JSON list.\")\n try:\n return [GitSkill.model_validate(item) for item in data]\n except ValidationError as e:\n sys.exit(f\"Invalid AGENT_GIT_SKILLS entry: {e}\")\n\n\ndef reconcile_git_skills(git_skills: list[GitSkill], state: SkillDownloaderState) -> list[str]:\n \"\"\"Delete dirs for git skills no longer desired; prune them from state. Returns removed names.\"\"\"\n desired_names = {g.name for g in git_skills}\n removed: list[str] = []\n for entry in state.downloaded_git_skills:\n if entry.name in desired_names:\n continue\n # Fail-closed prune: a failed delete aborts the run rather than leaving a stale skill on disk.\n # The name is dropped from tracking either way.\n _delete_skill_dir_by_name(entry.name, entry.name)\n removed.append(entry.name)\n state.downloaded_git_skills = [\n e for e in state.downloaded_git_skills if e.name in desired_names\n ]\n return removed\n\n\ndef _run_git(\n args: list[str], cwd: Path, input_text: str | None = None\n) -> subprocess.CompletedProcess[str]:\n \"\"\"Run a git subcommand non-interactively and fail-closed on error.\n\n Args are passed as a list (never a shell string) and git runs with a scrubbed environment:\n ambient global/system config and credential helpers are ignored (so nothing can rewrite the URL\n via insteadOf or inject creds) and prompting is disabled (so a private/typo repo fails fast\n instead of hanging on a username prompt).\"\"\"\n env = {\n **os.environ,\n \"GIT_TERMINAL_PROMPT\": \"0\", # never prompt for credentials — fail fast on a non-public repo\n \"GIT_CONFIG_NOSYSTEM\": \"1\", # ignore /etc/gitconfig\n \"GIT_CONFIG_GLOBAL\": \"/dev/null\", # ignore ~/.gitconfig (insteadOf rewrites, cred helpers)\n \"GCM_INTERACTIVE\": \"never\",\n }\n try:\n proc = subprocess.run(\n [\"git\", *args],\n cwd=os.fspath(cwd),\n env=env,\n input=input_text,\n capture_output=True,\n text=True,\n timeout=GIT_CLONE_TIMEOUT_SECONDS,\n )\n except subprocess.TimeoutExpired:\n raise GitSkillError(f\"git {args[0]} timed out after {GIT_CLONE_TIMEOUT_SECONDS}s\")\n except OSError as e:\n raise GitSkillError(f\"could not run git (is it installed in the sandbox?): {e}\")\n if proc.returncode != 0:\n detail = (proc.stderr or proc.stdout or \"\").strip()[-500:]\n raise GitSkillError(f\"git {args[0]} failed (exit {proc.returncode}): {detail}\")\n return proc\n\n\ndef _parse_ls_remote_object_id(stdout: str) -> str | None:\n \"\"\"Prefer a peeled `ref^{}` line (annotated tags), else the first object id.\"\"\"\n first: str | None = None\n for line in stdout.splitlines():\n trimmed = line.strip()\n if not trimmed:\n continue\n parts = trimmed.split(None, 1)\n sha = parts[0]\n name = parts[1] if len(parts) > 1 else \"\"\n if name.endswith(\"^{}\"):\n return sha\n if first is None:\n first = sha\n return first\n\n\ndef _resolve_object_id(url: str, ref: str, cwd: Path) -> str:\n \"\"\"Resolve a mount ref (branch/tag/SHA) to an object id inside the sandbox.\n\n Full object ids are used as-is (`git ls-remote` does not advertise bare SHAs on\n GitHub/GitLab). Branches and tags are resolved via `git ls-remote` so a branch tip\n advances on later sandbox inits.\n \"\"\"\n if OBJECT_ID_RE.match(ref):\n return ref\n proc = _run_git([\"ls-remote\", url, ref], cwd=cwd)\n oid = _parse_ls_remote_object_id(proc.stdout or \"\")\n if oid is None:\n raise GitSkillError(f\"git ls-remote returned no commit for {url} ref {ref}\")\n return oid\n\n\ndef _git_fetch_repo(\n url: str, object_id: str, paths: list[str], parent_dir: Path\n) -> Path:\n \"\"\"Object-id-pinned, shallow, blob-filtered sparse checkout of `paths` into a fresh repo under\n `parent_dir`, returning the repo working directory.\n\n When every requested path is the repo root (\"\") we can't sparse-restrict, so we do a full\n shallow checkout (all blobs). Otherwise we cone-sparse to just the requested paths and use\n --filter=blob:none so only those paths' blobs are fetched — the whole point of the approach.\n Paths are fed to `git sparse-checkout set` via stdin so a path value can never be read as an\n option/argument. Fetching by object id relies on the server allowing it, which github.com and\n gitlab.com both do.\"\"\"\n repo = parent_dir / \"repo\"\n _run_git([\"init\", \"-q\", os.fspath(repo)], cwd=parent_dir)\n _run_git([\"remote\", \"add\", \"origin\", url], cwd=repo)\n\n cone_dirs = [s for s in dict.fromkeys(paths) if s] # de-duped, root (\"\") dropped\n want_full_tree = any(s == \"\" for s in paths)\n if not want_full_tree and cone_dirs:\n _run_git([\"sparse-checkout\", \"init\", \"--cone\"], cwd=repo)\n _run_git(\n [\"sparse-checkout\", \"set\", \"--stdin\"],\n cwd=repo,\n input_text=\"\".join(f\"{d}\\n\" for d in cone_dirs),\n )\n\n fetch_args = [\"-c\", \"protocol.file.allow=never\", \"fetch\", \"-q\", \"--depth\", \"1\"]\n if not want_full_tree:\n fetch_args.append(\"--filter=blob:none\")\n fetch_args += [\"origin\", object_id]\n _run_git(fetch_args, cwd=repo)\n\n _run_git(\n [\"-c\", \"advice.detachedHead=false\", \"checkout\", \"-q\", \"--detach\", \"FETCH_HEAD\"], cwd=repo\n )\n return repo\n\n\ndef _installed_size_bytes(path: Path) -> int:\n \"\"\"Total size of regular files under `path`, excluding the repo's .git dir and without following\n symlinks (they're copied as links, not their targets), used to enforce GIT_SKILL_MAX_BYTES.\"\"\"\n total = 0\n for root, dirs, files in os.walk(path, followlinks=False):\n if \".git\" in dirs:\n dirs.remove(\".git\")\n for name in files:\n try:\n total += (Path(root) / name).lstat().st_size\n except OSError:\n continue\n return total\n\n\ndef _install_git_skill(repo_root: Path, skill: GitSkill) -> None:\n dest = skill_dir(skill.name)\n if dest is None:\n raise GitSkillError(f\"Git skill: unsafe directory name {skill.name!r}\")\n repo_root_resolved = repo_root.resolve()\n src = (repo_root / skill.path).resolve() if skill.path else repo_root_resolved\n # Defense in depth: even though path is validated, ensure the resolved source stays within the\n # cloned repo (guards against a symlinked directory component pointing outside the checkout).\n if src != repo_root_resolved and not src.is_relative_to(repo_root_resolved):\n raise GitSkillError(\n f\"Git skill {skill.name}: path '{skill.path}' escapes the repository\"\n )\n if not src.is_dir():\n raise GitSkillError(\n f\"Git skill {skill.name}: path '{skill.path}' not found in repository\"\n )\n if _installed_size_bytes(src) > GIT_SKILL_MAX_BYTES:\n raise GitSkillError(\n f\"Git skill {skill.name}: content exceeds {GIT_SKILL_MAX_BYTES} bytes\"\n )\n # Stage the fresh copy into a temp sibling dir, then swap it in with an atomic rename. `dest` is\n # only touched once the copy has fully succeeded, so a failed/partial copytree can never leave\n # the skill missing (which would contradict the path the gateway advertises in the prompt) — the\n # previously installed copy stays intact on failure. `.git` is excluded (only present for a repo-\n # root skill) and symlinks are copied as links, never followed, so a link inside the repo can't\n # pull external file contents into the skill dir at copy time.\n staging = dest.with_name(f\".{dest.name}.new-{os.getpid()}\")\n _rmtree(staging) # clear any leftover staging dir from an earlier aborted run\n try:\n shutil.copytree(src, staging, symlinks=True, ignore=shutil.ignore_patterns(\".git\"))\n except (OSError, shutil.Error) as e:\n # shutil.Error (a multi-file copy error, NOT an OSError subclass) must be caught here too so a\n # bad copy is reported as a per-skill GitSkillError (WARNING + counted failure) instead of an\n # uncaught traceback that aborts the whole run and skips save_state for the group.\n _rmtree(staging)\n raise GitSkillError(f\"Failed to stage git skill {skill.name}: {e}\")\n try:\n if dest.exists():\n shutil.rmtree(dest)\n # Same filesystem (both under SKILLS_ROOT), so this is an atomic rename; the window where the\n # skill dir is absent shrinks from a full copy to a single near-instant rename.\n os.replace(staging, dest)\n except OSError as e:\n _rmtree(staging)\n raise GitSkillError(f\"Failed to install git skill {skill.name}: {e}\")\n\n\ndef _mark_git_downloaded(\n state: SkillDownloaderState, skill: GitSkill, object_id: str\n) -> None:\n \"\"\"Upsert a git skill's installed object id + path so the next run can skip when both match.\"\"\"\n for entry in state.downloaded_git_skills:\n if entry.name == skill.name:\n entry.ref = object_id\n entry.path = skill.path\n return\n state.downloaded_git_skills.append(\n DownloadedGitSkill(name=skill.name, ref=object_id, path=skill.path)\n )\n\n\ndef _git_skill_already_installed(\n state: SkillDownloaderState, skill: GitSkill, object_id: str\n) -> bool:\n \"\"\"True only when the skill is recorded at the resolved object id AND path AND its directory is\n still present. Re-download otherwise (branch tip moved, path changed, first install, or the\n dir was removed out-of-band) so the prompt never points at a missing/stale skill dir.\"\"\"\n dir_ = skill_dir(skill.name)\n if dir_ is None or not dir_.is_dir():\n return False\n return any(\n e.name == skill.name and e.ref == object_id and e.path == skill.path\n for e in state.downloaded_git_skills\n )\n\n\ndef download_git_skills(\n git_skills: list[GitSkill], state: SkillDownloaderState\n) -> int:\n \"\"\"Ensure desired git skills are installed at their resolved object id, skipping any already\n present at that pin so an unchanged tip no longer re-clones every run.\n\n Ref resolution (`git ls-remote`) runs here in the sandbox — the host never spawns git.\n Failures are isolated per repo group and per skill so one bad repo/subdir doesn't prevent the\n rest from being attempted; each failure is logged to stderr. Returns the number of skills that are\n satisfied this run — freshly installed OR already present at the resolved pin (never raises).\n The caller (run_git_download) compares this to the number requested and exits non-zero if any are\n missing, so the overall behaviour is fail-closed.\"\"\"\n SKILLS_ROOT.mkdir(parents=True, exist_ok=True)\n\n # Resolve mount refs → object ids, then skip skills already installed at that pin.\n pending: list[tuple[GitSkill, str]] = []\n satisfied = 0\n for skill in git_skills:\n try:\n object_id = _resolve_object_id(skill.url, skill.ref, SKILLS_ROOT)\n except GitSkillError as e:\n print(f\"WARNING: {e} (skill: {skill.name})\", file=sys.stderr)\n continue\n if _git_skill_already_installed(state, skill, object_id):\n satisfied += 1\n else:\n pending.append((skill, object_id))\n\n # Group pending by (repo, object id) so a repo shared by several skills is cloned once and its\n # needed paths fetched together in a single sparse checkout.\n skills_by_repo: dict[tuple[str, str], list[tuple[GitSkill, str]]] = {}\n for skill, object_id in pending:\n skills_by_repo.setdefault((skill.url, object_id), []).append((skill, object_id))\n\n for (url, object_id), group in skills_by_repo.items():\n try:\n with tempfile.TemporaryDirectory(\n prefix=\".git-skill-dl-\",\n dir=os.fspath(SKILLS_ROOT),\n ignore_cleanup_errors=True,\n ) as tmp_str:\n repo_root = _git_fetch_repo(\n url,\n object_id,\n [s.path for s, _ in group],\n Path(tmp_str),\n )\n # Install each skill independently so a bad path only skips that one skill.\n for skill, oid in group:\n try:\n _install_git_skill(repo_root, skill)\n _mark_git_downloaded(state, skill, oid)\n satisfied += 1\n except GitSkillError as e:\n print(f\"WARNING: {e}\", file=sys.stderr)\n except GitSkillError as e:\n # Collect (don't abort-on-first) so EVERY failing skill is reported before the run exits;\n # run_git_download turns a non-zero failed count into a non-zero exit (fail-closed), which\n # fails the whole agent request. We still don't purge a previously installed copy here, so\n # a reused sandbox keeps the old files on disk even though this run is about to fail.\n names = \", \".join(s.name for s, _ in group)\n print(f\"WARNING: {e} (skills: {names})\", file=sys.stderr)\n save_state(state)\n return satisfied\n\n\ndef run_git_download() -> None:\n git_skills = git_skills_from_env(os.environ.get(\"AGENT_GIT_SKILLS\"))\n state = load_state()\n removed = reconcile_git_skills(git_skills, state)\n save_state(state) # persist pruning even when there is nothing to download\n\n if not git_skills:\n if removed:\n print(f\"Removed {len(removed)} git skill(s) from disk. (AGENT_GIT_SKILLS empty.)\")\n return\n\n satisfied = download_git_skills(git_skills, state)\n failed = len(git_skills) - satisfied\n suffix = f\" Removed {len(removed)} stale git skill(s).\" if removed else \"\"\n if failed:\n # Fail-closed: a requested skill that couldn't be installed exits non-zero so the gateway\n # (ensureExecSuccess) fails the whole agent request, matching the fail-closed SKILL.md fetch.\n # Per-skill WARNINGs above list exactly what failed.\n sys.exit(\n f\"Failed to install {failed}/{len(git_skills)} git skill(s); see warnings above.{suffix}\"\n )\n print(f\"Ensured {satisfied} git skill(s) (downloaded or already up to date).{suffix}\")\n\n\ndef main() -> None:\n run_git_download()\n\n\nif __name__ == \"__main__\":\n main()\n";
4
4
  };
5
5
  //# sourceMappingURL=sandboxScripts.gen.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"sandboxScripts.gen.d.ts","sourceRoot":"","sources":["../../../src/core/sandbox/sandboxScripts.gen.ts"],"names":[],"mappings":"AACA,eAAO,MAAM,cAAc;aACzB,SAAS,EAAE,+6XAA+6X;aAC17X,aAAa,EAAE,4+xBAA4+xB;CACn/xB,CAAC"}
1
+ {"version":3,"file":"sandboxScripts.gen.d.ts","sourceRoot":"","sources":["../../../src/core/sandbox/sandboxScripts.gen.ts"],"names":[],"mappings":"AACA,eAAO,MAAM,cAAc;aACzB,SAAS,EAAE,g8XAAg8X;aAC38X,aAAa,EAAE,4+xBAA4+xB;CACn/xB,CAAC"}
@@ -27,7 +27,7 @@ var sandboxScripts = {
27
27
  mcpClient: `#!/usr/bin/env python3
28
28
 
29
29
  # /// script
30
- # dependencies = ["pydantic==2.12.5", "nats-py==2.15.0"]
30
+ # dependencies = ["mcp==1.29.0", "pydantic==2.12.5", "nats-py==2.15.0"]
31
31
  # ///
32
32
 
33
33
  import os
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/core/sandbox/sandboxScripts.gen.ts"],"sourcesContent":["// AUTO-GENERATED by scripts/generate-sandbox-scripts.mjs — do not edit.\nexport const sandboxScripts = {\n mcpClient: \"#!/usr/bin/env python3\\n\\n# /// script\\n# dependencies = [\\\"pydantic==2.12.5\\\", \\\"nats-py==2.15.0\\\"]\\n# ///\\n\\nimport os\\nimport sys\\nimport json\\nimport time\\nimport base64\\nimport asyncio\\nimport logging\\nimport argparse\\nfrom pathlib import Path\\nfrom typing import Any, TypedDict\\n\\nfrom pydantic import BaseModel, ValidationError\\n\\nfrom mcp.types import CallToolResult, TextContent, Tool\\n\\nlogger = logging.getLogger(__name__)\\n\\n_NATS_REQUEST_MAX_ATTEMPTS = 3\\n_NATS_RETRY_BACKOFF_MS = 250\\n\\n_nats_url = os.environ.get(\\\"TFY_NATS_URL\\\")\\n_nats_subject_prefix = os.environ.get(\\\"TFY_NATS_SUBJECT_PREFIX\\\")\\n_NATS_SUBJECT = f\\\"{_nats_subject_prefix}.mcp\\\" if _nats_url and _nats_subject_prefix else None\\nif bool(_nats_url) != bool(_nats_subject_prefix):\\n raise RuntimeError(\\n \\\"TFY_NATS_URL and TFY_NATS_SUBJECT_PREFIX must be set together; \\\"\\n f\\\"got TFY_NATS_URL={'set' if _nats_url else 'unset'}, \\\"\\n f\\\"TFY_NATS_SUBJECT_PREFIX={'set' if _nats_subject_prefix else 'unset'}\\\"\\n ) from None\\n\\n_nats_trace_headers: dict[str, str] = {}\\nif _traceparent := os.environ.get(\\\"TFY_TRACEPARENT\\\"):\\n _nats_trace_headers[\\\"traceparent\\\"] = _traceparent\\nif _tracestate := os.environ.get(\\\"TFY_TRACESTATE\\\"):\\n _nats_trace_headers[\\\"tracestate\\\"] = _tracestate\\n\\n\\nclass _ToolsCache(BaseModel):\\n fetched_at: float\\n tools: list[Tool]\\n\\n\\nclass _BridgeReply(BaseModel):\\n \\\"\\\"\\\"NATS bridge reply envelope. `result` is left untyped — operation-specific callers\\n (Tool / CallToolResult) validate the payload; this model only validates the envelope.\\\"\\\"\\\"\\n\\n ok: bool\\n result: Any = None\\n error: str = \\\"unknown error\\\"\\n source: str = \\\"internal\\\"\\n\\n\\nclass _ServerConfig(TypedDict):\\n allowed_tools: list[str]\\n\\n\\n_TOOLS_CACHE_TTL_SECONDS = 600\\n\\n_inflight_list_tools: dict[str, asyncio.Task[list[Tool]]] = {}\\n\\n_raw_servers = os.environ.get(\\\"TFY_MCP_SERVERS\\\")\\n_servers_map: dict[str, _ServerConfig] = json.loads(base64.b64decode(_raw_servers).decode()) if _raw_servers else {}\\n# Approvals are always enabled (Sandbox always injects TFY_ENABLE_AGENT_APPROVALS=true).\\n_enable_agent_approvals = os.environ.get(\\\"TFY_ENABLE_AGENT_APPROVALS\\\", \\\"true\\\").lower() == \\\"true\\\"\\n\\n\\ndef _check_tool_allowed(server: str, tool_name: str) -> None:\\n server_config = _servers_map.get(server)\\n if server_config is None:\\n raise RuntimeError(f\\\"Access denied: MCP server '{server}' is not available for this agent\\\") from None\\n server_tools = server_config.get(\\\"allowed_tools\\\") or []\\n if len(server_tools) > 0 and tool_name not in server_tools:\\n raise RuntimeError(f\\\"Access denied: tool '{tool_name}' is not enabled on server '{server}'\\\") from None\\n\\n\\ndef _cache_path(server: str) -> Path:\\n return Path(__file__).parent / f\\\"{server}.tools.json\\\"\\n\\n\\ndef _read_tools_cache(server: str) -> _ToolsCache | None:\\n p = _cache_path(server)\\n if not p.exists():\\n return None\\n try:\\n cache = _ToolsCache.model_validate_json(p.read_text(encoding=\\\"utf-8\\\"))\\n except Exception:\\n logger.exception(\\\"_ToolsCache.model_validate_json\\\")\\n p.unlink(missing_ok=True)\\n return None\\n if time.time() - cache.fetched_at > _TOOLS_CACHE_TTL_SECONDS:\\n p.unlink(missing_ok=True)\\n return None\\n return cache\\n\\n\\ndef _write_tools_cache(server: str, tools: list[Tool]) -> None:\\n try:\\n cache = _ToolsCache(fetched_at=time.time(), tools=tools)\\n _cache_path(server).write_text(cache.model_dump_json(), encoding=\\\"utf-8\\\")\\n except Exception:\\n logger.exception(\\\"_write_tools_cache\\\")\\n\\n\\ndef _require_nats_subject() -> str:\\n if not _NATS_SUBJECT or not _nats_url:\\n raise RuntimeError(\\n \\\"NATS bridge setup failed; NATS bridge is required for MCP from Code Mode. \\\"\\n \\\"Try again.\\\"\\n ) from None\\n return _NATS_SUBJECT\\n\\n\\nasync def _fetch_tools(server: str) -> list[Tool]:\\n result = await _nats_request(_require_nats_subject(), {\\\"op\\\": \\\"list_tools\\\", \\\"server\\\": server})\\n if not isinstance(result, dict):\\n raise RuntimeError(f\\\"NATS list_tools '{server}' returned unexpected shape: {result!r}\\\") from None\\n return [Tool.model_validate(t) for t in result.get(\\\"tools\\\", [])]\\n\\n\\nasync def _fetch_and_cache_tools(server: str) -> list[Tool]:\\n tools = await _fetch_tools(server)\\n _write_tools_cache(server, tools)\\n return tools\\n\\n\\nasync def _nats_request(subject: str, payload: dict[str, Any]) -> Any:\\n \\\"\\\"\\\"Send one request over a short-lived bridge connection, unwrap { ok, result|error }.\\n\\n Connection is per-call (closed in `finally`) to avoid leaking nats-py's aiohttp session.\\n Connect and request are retried in separate loops so a successful connection isn't torn\\n down and rebuilt on a request-level retry. Retries only failures that prove the request\\n never reached a handler (connect failure, NoRespondersError); timeouts and post-delivery\\n failures are not retried so a non-idempotent tool can't double-execute.\\n \\\"\\\"\\\"\\n import nats\\n from nats.errors import NoRespondersError\\n\\n data = json.dumps(payload).encode()\\n request_timeout = float(os.environ[\\\"TFY_CM_REQUEST_TIMEOUT_SECONDS\\\"])\\n\\n # Phase 1: establish a connection, retrying only connect failures.\\n nc = None\\n last_connect_error = \\\"unknown error\\\"\\n for attempt in range(1, _NATS_REQUEST_MAX_ATTEMPTS + 1):\\n if attempt > 1:\\n await asyncio.sleep(_NATS_RETRY_BACKOFF_MS / 1000)\\n try:\\n nc = await nats.connect(_nats_url)\\n break\\n except Exception as e:\\n last_connect_error = f\\\"connect to NATS at {_nats_url} failed: {e}\\\"\\n if nc is None:\\n raise RuntimeError(\\n f\\\"NATS connect failed for '{subject}' after {_NATS_REQUEST_MAX_ATTEMPTS} attempts: {last_connect_error}\\\"\\n ) from None\\n\\n # Phase 2: request over the established connection, retrying only NoRespondersError.\\n try:\\n last_transport_error = \\\"unknown error\\\"\\n for attempt in range(1, _NATS_REQUEST_MAX_ATTEMPTS + 1):\\n if attempt > 1:\\n await asyncio.sleep(_NATS_RETRY_BACKOFF_MS / 1000)\\n try:\\n msg = await nc.request(\\n subject,\\n data,\\n timeout=request_timeout,\\n headers=_nats_trace_headers or None,\\n )\\n except NoRespondersError as e:\\n # Server confirms no subscriber — never delivered, safe to retry.\\n last_transport_error = f\\\"no NATS responder for '{subject}': {e}\\\"\\n continue\\n except Exception as e:\\n # Timeout / other: the tool may already have run, so we must not retry.\\n raise RuntimeError(f\\\"NATS request '{subject}' failed: {e}\\\") from None\\n try:\\n reply = _BridgeReply.model_validate_json(msg.data.decode())\\n except ValidationError as e:\\n raise RuntimeError(f\\\"NATS reply on '{subject}' is malformed: {e}\\\") from None\\n if not reply.ok:\\n # A well-formed reply means the transport worked; `source` says who's at fault.\\n if reply.source == \\\"caller\\\":\\n raise RuntimeError(f\\\"Invalid MCP request on '{subject}': {reply.error}\\\") from None\\n if reply.source == \\\"transport\\\":\\n raise RuntimeError(f\\\"Code Mode transport error on '{subject}': {reply.error}\\\") from None\\n raise RuntimeError(f\\\"Internal MCP error on '{subject}': {reply.error}\\\") from None\\n return reply.result\\n raise RuntimeError(\\n f\\\"NATS bridge has no responder for '{subject}' after {_NATS_REQUEST_MAX_ATTEMPTS} attempts: {last_transport_error}\\\"\\n ) from None\\n finally:\\n try:\\n await nc.close()\\n except Exception:\\n logger.debug(\\\"Error closing NATS connection\\\", exc_info=True)\\n\\n\\nasync def _get_tools(server: str) -> list[Tool]:\\n cache = _read_tools_cache(server)\\n if cache is not None:\\n return cache.tools\\n task = _inflight_list_tools.get(server)\\n if task is None:\\n task = asyncio.create_task(_fetch_and_cache_tools(server))\\n _inflight_list_tools[server] = task\\n task.add_done_callback(lambda _t: _inflight_list_tools.pop(server, None))\\n return await task\\n\\n\\nasync def _get_tool(server: str, tool_name: str) -> Tool | None:\\n for t in await _get_tools(server):\\n if t.name == tool_name:\\n return t\\n return None\\n\\n\\ndef _is_destructive(tool: Tool) -> bool:\\n annotations = tool.annotations\\n if annotations is None:\\n return False\\n return bool(annotations.destructiveHint) or (not annotations.readOnlyHint and annotations.readOnlyHint is not None)\\n\\nasync def _ensure_non_destructive(server: str, tool_name: str) -> None:\\n tool = await _get_tool(server, tool_name)\\n if tool is None:\\n raise RuntimeError(f\\\"Tool '{tool_name}' not found on MCP server '{server}'\\\") from None\\n if _is_destructive(tool):\\n raise RuntimeError(\\n f\\\"Tool '{tool_name}' on MCP server '{server}' is destructive and cannot be called in Code Mode; \\\"\\n f\\\"call it directly so it can go through the user approval flow\\\"\\n ) from None\\n\\n\\ndef _project_call_tool_result(server: str, tool: str, result: CallToolResult) -> Any:\\n \\\"\\\"\\\"Project an MCP-wire CallToolResult into the user-facing Python value.\\\"\\\"\\\"\\n if result.isError:\\n text_parts = [c.text for c in result.content if isinstance(c, TextContent) and c.text]\\n msg = \\\"; \\\".join(text_parts) if text_parts else \\\"tool returned an error\\\"\\n raise RuntimeError(f\\\"MCP tool error (server={server}, tool={tool}): {msg}\\\") from None\\n\\n if result.structuredContent is not None:\\n return result.structuredContent\\n\\n # Fallback to content blocks (tools without outputSchema / plain text responses)\\n if len(result.content) == 1:\\n first = result.content[0]\\n if isinstance(first, TextContent) and first.text:\\n try:\\n return json.loads(first.text)\\n except Exception:\\n pass\\n if result.content:\\n return result.content\\n return None\\n\\n\\nasync def call_tool(server: str, tool: str, body: dict[str, Any]) -> Any:\\n _check_tool_allowed(server, tool)\\n if _enable_agent_approvals:\\n await _ensure_non_destructive(server, tool)\\n\\n raw = await _nats_request(\\n _require_nats_subject(),\\n {\\\"op\\\": \\\"call_tool\\\", \\\"server\\\": server, \\\"tool\\\": tool, \\\"arguments\\\": body},\\n )\\n try:\\n result = CallToolResult.model_validate(raw)\\n except Exception as e:\\n raise RuntimeError(f\\\"NATS call_tool reply for '{server}/{tool}' is malformed: {e}\\\") from None\\n\\n return _project_call_tool_result(server, tool, result)\\n\\n\\n_USAGE = \\\"mcp_client.py call-tool <server> <tool> <args-json>\\\"\\n\\n\\ndef _build_arg_parser() -> argparse.ArgumentParser:\\n parser = argparse.ArgumentParser(prog=\\\"mcp_client.py\\\", usage=_USAGE)\\n sub = parser.add_subparsers(dest=\\\"cmd\\\", required=True)\\n\\n call_tool_p = sub.add_parser(\\\"call-tool\\\", help=\\\"Invoke an MCP tool\\\")\\n call_tool_p.add_argument(\\\"server\\\")\\n call_tool_p.add_argument(\\\"tool\\\")\\n # Parsing JSON at the argparse boundary surfaces malformed input as a clean\\n # argparse error (with usage) instead of an opaque traceback from inside the handler.\\n call_tool_p.add_argument(\\\"args_json\\\", metavar=\\\"args-json\\\", type=json.loads)\\n\\n return parser\\n\\n\\nasync def _main() -> None:\\n args = _build_arg_parser().parse_args()\\n try:\\n if args.cmd == \\\"call-tool\\\":\\n result = await call_tool(args.server, args.tool, args.args_json)\\n print(json.dumps(result, default=str))\\n except RuntimeError as e:\\n sys.exit(str(e))\\n\\n\\nif __name__ == \\\"__main__\\\":\\n asyncio.run(_main())\\n\",\n gitDownloader: \"#!/usr/bin/env python3\\n\\n# /// script\\n# dependencies = [\\\"pydantic==2.12.5\\\"]\\n# ///\\n\\n\\\"\\\"\\\"Materialize git skill directories into TFY_SKILLS_DIR (default /opt/tfy/skills). Requires `git`.\\n\\nEach git skill (AGENT_GIT_SKILLS) is materialized with a blob-filtered sparse `git` clone that\\nfetches only the requested subdir at a resolved object id, keyed by name. Mount `ref` values\\n(branch, tag, or full object id) are resolved here via `git ls-remote` — the host never spawns\\ngit. A single on-disk state file records the installed object id + subdir per name so a skill\\nwhose tip hasn't moved is skipped instead of re-cloning every run, and skills no longer desired\\nare pruned from disk. A sparse clone is used instead of a full repo tarball because its cost is\\n~constant in the subdir size rather than the whole-repo size, which is dramatically faster for a\\nsmall skill living in a large monorepo.\\n\\nOptional env:\\n - AGENT_GIT_SKILLS (base64-encoded JSON list of {name, url, path, ref}; empty\\n clears git downloads). `ref` is a branch, tag, or full object id.\\n - TFY_SKILLS_DIR: override the skills directory (default /opt/tfy/skills).\\n\\\"\\\"\\\"\\n\\nfrom __future__ import annotations\\n\\nimport base64\\nimport json\\nimport os\\nimport re\\nimport shutil\\nimport subprocess\\nimport sys\\nimport tempfile\\nfrom pathlib import Path\\n\\nfrom pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator\\n\\nDEFAULT_SKILLS_DIR = \\\"/opt/tfy/skills\\\"\\nSTATE_FILE_NAME = \\\".tfy-skill-downloader-state.json\\\"\\n# Per-git-invocation wall-clock cap so a hung/slow fetch can't stall sandbox init indefinitely.\\nGIT_CLONE_TIMEOUT_SECONDS = 120\\n# Cap the installed skill size so a huge repo-root skill can't fill the persistent skills dir. The\\n# sparse clone already bounds a subdir skill to its subdir; this also guards the whole-repo (root\\n# subdir) case where checkout hydrates every file.\\nGIT_SKILL_MAX_BYTES = 200 * 1024 * 1024 # 200MB installed\\n# Resolved object id used for fetch + skip state (sha1 / sha256).\\nOBJECT_ID_RE = re.compile(r\\\"^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$\\\")\\n# Mount ref (branch/tag/SHA) charset — must match host AgentSpec SkillMount.ref validation.\\n# Validated before the value is passed to `git ls-remote` / used as a fetch arg fallback.\\nGIT_REF_RE = re.compile(r\\\"^[A-Za-z0-9._\\\\-/]+$\\\")\\nSKILLS_ROOT = Path(os.environ.get(\\\"TFY_SKILLS_DIR\\\", DEFAULT_SKILLS_DIR))\\nSTATE_PATH = SKILLS_ROOT / STATE_FILE_NAME\\n\\n\\nclass GitSkillError(Exception):\\n \\\"\\\"\\\"Recoverable per-repo/per-skill git failure. Raised (not sys.exit) so a single bad repo or\\n subdirectory doesn't abort the remaining repository groups (best-effort failure isolation).\\\"\\\"\\\"\\n\\n\\ndef skill_dir(name: str) -> Path | None:\\n \\\"\\\"\\\"Resolve a skill's on-disk directory. This is the ONLY place a skill name becomes a path, so\\n the safety check here is what guarantees that no name — freshly requested or read back from the\\n (possibly corrupt) state file — can escape SKILLS_ROOT during delete/copy.\\n\\n Returns None for an unsafe name (empty, absolute, containing a path separator, \\\".\\\" / \\\"..\\\", or the\\n reserved state-file name) so each caller can skip it per its own failure contract instead of\\n touching an out-of-tree path. The state-file name is reserved because a skill whose name equals it\\n would resolve to STATE_PATH and let a skill install clobber the reconciliation state.\\n \\\"\\\"\\\"\\n if not name or name in (\\\".\\\", \\\"..\\\") or \\\"/\\\" in name or \\\"\\\\\\\\\\\" in name or name == STATE_FILE_NAME:\\n return None\\n return SKILLS_ROOT / name\\n\\n\\nclass GitSkill(BaseModel):\\n \\\"\\\"\\\"A git skill to materialize: `name`, `url`, `path`, and mount `ref` (branch, tag, or object id).\\n This script resolves `ref` via `git ls-remote` in the sandbox, then sparse-fetches the resolved\\n object id. The url and ref are format-validated here (defense in depth) so an unexpected value\\n can't be interpreted as a git option or reach a non-public host.\\\"\\\"\\\"\\n\\n model_config = ConfigDict(extra=\\\"ignore\\\")\\n\\n name: str\\n url: str\\n path: str = \\\"\\\"\\n # Branch, tag, or full object id from the agent_spec mount (resolved in-sandbox before fetch).\\n ref: str\\n\\n @field_validator(\\\"url\\\")\\n @classmethod\\n def _validate_url(cls, v: str) -> str:\\n # Only the two public hosts the gateway supports. Anchoring on the \\\"https://<host>/\\\" prefix\\n # blocks non-https schemes (file://, ext::, ssh) and any other host, so this URL can't be\\n # turned into an SSRF/local-file/command vector once it reaches `git`.\\n if not (v.startswith(\\\"https://github.com/\\\") or v.startswith(\\\"https://gitlab.com/\\\")):\\n raise ValueError(f\\\"git skill url must be a github.com/gitlab.com https URL: {v!r}\\\")\\n return v\\n\\n @field_validator(\\\"ref\\\")\\n @classmethod\\n def _validate_ref(cls, v: str) -> str:\\n if not v or \\\"..\\\" in v.split(\\\"/\\\") or not GIT_REF_RE.match(v):\\n raise ValueError(f\\\"git skill ref is invalid: {v!r}\\\")\\n if v.replace(\\\"/\\\", \\\"\\\") == \\\"\\\":\\n raise ValueError(f\\\"git skill ref must not consist only of slashes: {v!r}\\\")\\n return v\\n\\n @field_validator(\\\"path\\\")\\n @classmethod\\n def _validate_path(cls, v: str) -> str:\\n # Relative subpath within the repo. Reject absolute paths and \\\"..\\\" segments so it can't\\n # escape the repo root. Leading/trailing slashes are stripped (repo root == \\\"\\\"). A leading\\n # \\\"-\\\" is safe because subdirs are fed to `git sparse-checkout set` via stdin, never as args.\\n if v.startswith(\\\"/\\\"):\\n raise ValueError(f\\\"git skill path must be relative: {v!r}\\\")\\n stripped = v.strip(\\\"/\\\")\\n if stripped and \\\"..\\\" in stripped.split(\\\"/\\\"):\\n raise ValueError(f\\\"git skill path must not contain '..': {v!r}\\\")\\n return stripped\\n\\n\\nclass DownloadedGitSkill(BaseModel):\\n \\\"\\\"\\\"A git skill recorded on disk: its dir name, the object id it was installed at, and the repo\\n path it was installed from. The ref lets a later run skip re-downloading when the requested\\n pin is unchanged; the path is part of the identity so that changing the path at the same\\n pin still triggers a re-download instead of serving the previously-installed layout.\\\"\\\"\\\"\\n\\n model_config = ConfigDict(extra=\\\"ignore\\\")\\n\\n name: str\\n # Empty when migrated from a pre-pin-tracking state file — treated as \\\"unknown\\\", forcing a\\n # one-time re-download while still keeping the name tracked for pruning.\\n ref: str = \\\"\\\"\\n # Repo path the skill was installed from (repo root == \\\"\\\"). Empty on entries written before\\n # path tracking; a mismatch against the requested path forces a one-time re-download.\\n path: str = \\\"\\\"\\n\\n\\nclass SkillDownloaderState(BaseModel):\\n \\\"\\\"\\\"Persisted next to skills; tracks what was downloaded so undesired skills can be pruned.\\\"\\\"\\\"\\n\\n model_config = ConfigDict(extra=\\\"ignore\\\")\\n\\n downloaded_git_skills: list[DownloadedGitSkill] = Field(\\n default_factory=list,\\n description=\\\"Git skills successfully downloaded (name + installed object id).\\\",\\n )\\n\\n\\ndef _migrate_downloaded_git_skills(entries: list[object]) -> list[dict[str, object]]:\\n \\\"\\\"\\\"Normalize state entries: rename legacy `commit_sha` → `ref` and `subdir` → `path`.\\\"\\\"\\\"\\n migrated: list[dict[str, object]] = []\\n for item in entries:\\n if not isinstance(item, dict):\\n continue\\n entry = dict(item)\\n if \\\"ref\\\" not in entry and isinstance(entry.get(\\\"commit_sha\\\"), str):\\n entry[\\\"ref\\\"] = entry[\\\"commit_sha\\\"]\\n entry.pop(\\\"commit_sha\\\", None)\\n if \\\"path\\\" not in entry and isinstance(entry.get(\\\"subdir\\\"), str):\\n entry[\\\"path\\\"] = entry[\\\"subdir\\\"]\\n entry.pop(\\\"subdir\\\", None)\\n migrated.append(entry)\\n return migrated\\n\\n\\ndef load_state() -> SkillDownloaderState:\\n if not STATE_PATH.is_file():\\n return SkillDownloaderState()\\n try:\\n raw = json.loads(STATE_PATH.read_text(encoding=\\\"utf-8\\\"))\\n except (OSError, ValueError):\\n return SkillDownloaderState()\\n if not isinstance(raw, dict):\\n return SkillDownloaderState()\\n # Migrate a pre-pin-tracking state file: its `downloaded_git_names` (list of names, no pin)\\n # becomes entries with an empty ref. That keeps the names tracked so undesired skills are\\n # still pruned, and forces a one-time re-download (empty != any requested pin). Skipped when\\n # the new field is already present so we never clobber real pins.\\n if \\\"downloaded_git_skills\\\" not in raw and isinstance(raw.get(\\\"downloaded_git_names\\\"), list):\\n raw[\\\"downloaded_git_skills\\\"] = [\\n {\\\"name\\\": n, \\\"ref\\\": \\\"\\\"} for n in raw[\\\"downloaded_git_names\\\"] if isinstance(n, str)\\n ]\\n if isinstance(raw.get(\\\"downloaded_git_skills\\\"), list):\\n raw[\\\"downloaded_git_skills\\\"] = _migrate_downloaded_git_skills(raw[\\\"downloaded_git_skills\\\"])\\n try:\\n return SkillDownloaderState.model_validate(raw)\\n except ValidationError:\\n return SkillDownloaderState()\\n\\n\\ndef save_state(state: SkillDownloaderState) -> None:\\n STATE_PATH.parent.mkdir(parents=True, exist_ok=True)\\n tmp = STATE_PATH.with_name(STATE_PATH.name + \\\".tmp\\\")\\n tmp.write_text(state.model_dump_json(indent=2), encoding=\\\"utf-8\\\")\\n os.replace(tmp, STATE_PATH)\\n\\n\\ndef _rmtree(path: Path, ignore_errors: bool = True) -> None:\\n try:\\n shutil.rmtree(path, ignore_errors=ignore_errors)\\n except OSError:\\n pass\\n\\n\\ndef _delete_skill_dir_by_name(name: str, label: str) -> None:\\n \\\"\\\"\\\"Fail-closed prune of a no-longer-desired skill's directory: abort the whole run if removal\\n fails so we never silently leave a stale skill on disk. skill_dir() returns None for a\\n corrupt/tampered name, so rmtree never runs on an out-of-tree path.\\\"\\\"\\\"\\n dir_ = skill_dir(name)\\n if dir_ is None or not dir_.is_dir():\\n return\\n try:\\n shutil.rmtree(dir_)\\n except OSError as e:\\n sys.exit(f\\\"Could not remove skill directory {dir_} ({label}): {e}\\\")\\n\\n\\ndef git_skills_from_env(raw: str | None) -> list[GitSkill]:\\n \\\"\\\"\\\"Empty or missing env means \\\"no desired git skills\\\" (prune all git downloads).\\\"\\\"\\\"\\n if raw is None or not str(raw).strip():\\n return []\\n try:\\n data = json.loads(base64.b64decode(raw))\\n except (ValueError, json.JSONDecodeError) as e:\\n sys.exit(f\\\"AGENT_GIT_SKILLS is not valid base64-encoded JSON: {e}\\\")\\n if not isinstance(data, list):\\n sys.exit(\\\"AGENT_GIT_SKILLS must be a JSON list.\\\")\\n try:\\n return [GitSkill.model_validate(item) for item in data]\\n except ValidationError as e:\\n sys.exit(f\\\"Invalid AGENT_GIT_SKILLS entry: {e}\\\")\\n\\n\\ndef reconcile_git_skills(git_skills: list[GitSkill], state: SkillDownloaderState) -> list[str]:\\n \\\"\\\"\\\"Delete dirs for git skills no longer desired; prune them from state. Returns removed names.\\\"\\\"\\\"\\n desired_names = {g.name for g in git_skills}\\n removed: list[str] = []\\n for entry in state.downloaded_git_skills:\\n if entry.name in desired_names:\\n continue\\n # Fail-closed prune: a failed delete aborts the run rather than leaving a stale skill on disk.\\n # The name is dropped from tracking either way.\\n _delete_skill_dir_by_name(entry.name, entry.name)\\n removed.append(entry.name)\\n state.downloaded_git_skills = [\\n e for e in state.downloaded_git_skills if e.name in desired_names\\n ]\\n return removed\\n\\n\\ndef _run_git(\\n args: list[str], cwd: Path, input_text: str | None = None\\n) -> subprocess.CompletedProcess[str]:\\n \\\"\\\"\\\"Run a git subcommand non-interactively and fail-closed on error.\\n\\n Args are passed as a list (never a shell string) and git runs with a scrubbed environment:\\n ambient global/system config and credential helpers are ignored (so nothing can rewrite the URL\\n via insteadOf or inject creds) and prompting is disabled (so a private/typo repo fails fast\\n instead of hanging on a username prompt).\\\"\\\"\\\"\\n env = {\\n **os.environ,\\n \\\"GIT_TERMINAL_PROMPT\\\": \\\"0\\\", # never prompt for credentials — fail fast on a non-public repo\\n \\\"GIT_CONFIG_NOSYSTEM\\\": \\\"1\\\", # ignore /etc/gitconfig\\n \\\"GIT_CONFIG_GLOBAL\\\": \\\"/dev/null\\\", # ignore ~/.gitconfig (insteadOf rewrites, cred helpers)\\n \\\"GCM_INTERACTIVE\\\": \\\"never\\\",\\n }\\n try:\\n proc = subprocess.run(\\n [\\\"git\\\", *args],\\n cwd=os.fspath(cwd),\\n env=env,\\n input=input_text,\\n capture_output=True,\\n text=True,\\n timeout=GIT_CLONE_TIMEOUT_SECONDS,\\n )\\n except subprocess.TimeoutExpired:\\n raise GitSkillError(f\\\"git {args[0]} timed out after {GIT_CLONE_TIMEOUT_SECONDS}s\\\")\\n except OSError as e:\\n raise GitSkillError(f\\\"could not run git (is it installed in the sandbox?): {e}\\\")\\n if proc.returncode != 0:\\n detail = (proc.stderr or proc.stdout or \\\"\\\").strip()[-500:]\\n raise GitSkillError(f\\\"git {args[0]} failed (exit {proc.returncode}): {detail}\\\")\\n return proc\\n\\n\\ndef _parse_ls_remote_object_id(stdout: str) -> str | None:\\n \\\"\\\"\\\"Prefer a peeled `ref^{}` line (annotated tags), else the first object id.\\\"\\\"\\\"\\n first: str | None = None\\n for line in stdout.splitlines():\\n trimmed = line.strip()\\n if not trimmed:\\n continue\\n parts = trimmed.split(None, 1)\\n sha = parts[0]\\n name = parts[1] if len(parts) > 1 else \\\"\\\"\\n if name.endswith(\\\"^{}\\\"):\\n return sha\\n if first is None:\\n first = sha\\n return first\\n\\n\\ndef _resolve_object_id(url: str, ref: str, cwd: Path) -> str:\\n \\\"\\\"\\\"Resolve a mount ref (branch/tag/SHA) to an object id inside the sandbox.\\n\\n Full object ids are used as-is (`git ls-remote` does not advertise bare SHAs on\\n GitHub/GitLab). Branches and tags are resolved via `git ls-remote` so a branch tip\\n advances on later sandbox inits.\\n \\\"\\\"\\\"\\n if OBJECT_ID_RE.match(ref):\\n return ref\\n proc = _run_git([\\\"ls-remote\\\", url, ref], cwd=cwd)\\n oid = _parse_ls_remote_object_id(proc.stdout or \\\"\\\")\\n if oid is None:\\n raise GitSkillError(f\\\"git ls-remote returned no commit for {url} ref {ref}\\\")\\n return oid\\n\\n\\ndef _git_fetch_repo(\\n url: str, object_id: str, paths: list[str], parent_dir: Path\\n) -> Path:\\n \\\"\\\"\\\"Object-id-pinned, shallow, blob-filtered sparse checkout of `paths` into a fresh repo under\\n `parent_dir`, returning the repo working directory.\\n\\n When every requested path is the repo root (\\\"\\\") we can't sparse-restrict, so we do a full\\n shallow checkout (all blobs). Otherwise we cone-sparse to just the requested paths and use\\n --filter=blob:none so only those paths' blobs are fetched — the whole point of the approach.\\n Paths are fed to `git sparse-checkout set` via stdin so a path value can never be read as an\\n option/argument. Fetching by object id relies on the server allowing it, which github.com and\\n gitlab.com both do.\\\"\\\"\\\"\\n repo = parent_dir / \\\"repo\\\"\\n _run_git([\\\"init\\\", \\\"-q\\\", os.fspath(repo)], cwd=parent_dir)\\n _run_git([\\\"remote\\\", \\\"add\\\", \\\"origin\\\", url], cwd=repo)\\n\\n cone_dirs = [s for s in dict.fromkeys(paths) if s] # de-duped, root (\\\"\\\") dropped\\n want_full_tree = any(s == \\\"\\\" for s in paths)\\n if not want_full_tree and cone_dirs:\\n _run_git([\\\"sparse-checkout\\\", \\\"init\\\", \\\"--cone\\\"], cwd=repo)\\n _run_git(\\n [\\\"sparse-checkout\\\", \\\"set\\\", \\\"--stdin\\\"],\\n cwd=repo,\\n input_text=\\\"\\\".join(f\\\"{d}\\\\n\\\" for d in cone_dirs),\\n )\\n\\n fetch_args = [\\\"-c\\\", \\\"protocol.file.allow=never\\\", \\\"fetch\\\", \\\"-q\\\", \\\"--depth\\\", \\\"1\\\"]\\n if not want_full_tree:\\n fetch_args.append(\\\"--filter=blob:none\\\")\\n fetch_args += [\\\"origin\\\", object_id]\\n _run_git(fetch_args, cwd=repo)\\n\\n _run_git(\\n [\\\"-c\\\", \\\"advice.detachedHead=false\\\", \\\"checkout\\\", \\\"-q\\\", \\\"--detach\\\", \\\"FETCH_HEAD\\\"], cwd=repo\\n )\\n return repo\\n\\n\\ndef _installed_size_bytes(path: Path) -> int:\\n \\\"\\\"\\\"Total size of regular files under `path`, excluding the repo's .git dir and without following\\n symlinks (they're copied as links, not their targets), used to enforce GIT_SKILL_MAX_BYTES.\\\"\\\"\\\"\\n total = 0\\n for root, dirs, files in os.walk(path, followlinks=False):\\n if \\\".git\\\" in dirs:\\n dirs.remove(\\\".git\\\")\\n for name in files:\\n try:\\n total += (Path(root) / name).lstat().st_size\\n except OSError:\\n continue\\n return total\\n\\n\\ndef _install_git_skill(repo_root: Path, skill: GitSkill) -> None:\\n dest = skill_dir(skill.name)\\n if dest is None:\\n raise GitSkillError(f\\\"Git skill: unsafe directory name {skill.name!r}\\\")\\n repo_root_resolved = repo_root.resolve()\\n src = (repo_root / skill.path).resolve() if skill.path else repo_root_resolved\\n # Defense in depth: even though path is validated, ensure the resolved source stays within the\\n # cloned repo (guards against a symlinked directory component pointing outside the checkout).\\n if src != repo_root_resolved and not src.is_relative_to(repo_root_resolved):\\n raise GitSkillError(\\n f\\\"Git skill {skill.name}: path '{skill.path}' escapes the repository\\\"\\n )\\n if not src.is_dir():\\n raise GitSkillError(\\n f\\\"Git skill {skill.name}: path '{skill.path}' not found in repository\\\"\\n )\\n if _installed_size_bytes(src) > GIT_SKILL_MAX_BYTES:\\n raise GitSkillError(\\n f\\\"Git skill {skill.name}: content exceeds {GIT_SKILL_MAX_BYTES} bytes\\\"\\n )\\n # Stage the fresh copy into a temp sibling dir, then swap it in with an atomic rename. `dest` is\\n # only touched once the copy has fully succeeded, so a failed/partial copytree can never leave\\n # the skill missing (which would contradict the path the gateway advertises in the prompt) — the\\n # previously installed copy stays intact on failure. `.git` is excluded (only present for a repo-\\n # root skill) and symlinks are copied as links, never followed, so a link inside the repo can't\\n # pull external file contents into the skill dir at copy time.\\n staging = dest.with_name(f\\\".{dest.name}.new-{os.getpid()}\\\")\\n _rmtree(staging) # clear any leftover staging dir from an earlier aborted run\\n try:\\n shutil.copytree(src, staging, symlinks=True, ignore=shutil.ignore_patterns(\\\".git\\\"))\\n except (OSError, shutil.Error) as e:\\n # shutil.Error (a multi-file copy error, NOT an OSError subclass) must be caught here too so a\\n # bad copy is reported as a per-skill GitSkillError (WARNING + counted failure) instead of an\\n # uncaught traceback that aborts the whole run and skips save_state for the group.\\n _rmtree(staging)\\n raise GitSkillError(f\\\"Failed to stage git skill {skill.name}: {e}\\\")\\n try:\\n if dest.exists():\\n shutil.rmtree(dest)\\n # Same filesystem (both under SKILLS_ROOT), so this is an atomic rename; the window where the\\n # skill dir is absent shrinks from a full copy to a single near-instant rename.\\n os.replace(staging, dest)\\n except OSError as e:\\n _rmtree(staging)\\n raise GitSkillError(f\\\"Failed to install git skill {skill.name}: {e}\\\")\\n\\n\\ndef _mark_git_downloaded(\\n state: SkillDownloaderState, skill: GitSkill, object_id: str\\n) -> None:\\n \\\"\\\"\\\"Upsert a git skill's installed object id + path so the next run can skip when both match.\\\"\\\"\\\"\\n for entry in state.downloaded_git_skills:\\n if entry.name == skill.name:\\n entry.ref = object_id\\n entry.path = skill.path\\n return\\n state.downloaded_git_skills.append(\\n DownloadedGitSkill(name=skill.name, ref=object_id, path=skill.path)\\n )\\n\\n\\ndef _git_skill_already_installed(\\n state: SkillDownloaderState, skill: GitSkill, object_id: str\\n) -> bool:\\n \\\"\\\"\\\"True only when the skill is recorded at the resolved object id AND path AND its directory is\\n still present. Re-download otherwise (branch tip moved, path changed, first install, or the\\n dir was removed out-of-band) so the prompt never points at a missing/stale skill dir.\\\"\\\"\\\"\\n dir_ = skill_dir(skill.name)\\n if dir_ is None or not dir_.is_dir():\\n return False\\n return any(\\n e.name == skill.name and e.ref == object_id and e.path == skill.path\\n for e in state.downloaded_git_skills\\n )\\n\\n\\ndef download_git_skills(\\n git_skills: list[GitSkill], state: SkillDownloaderState\\n) -> int:\\n \\\"\\\"\\\"Ensure desired git skills are installed at their resolved object id, skipping any already\\n present at that pin so an unchanged tip no longer re-clones every run.\\n\\n Ref resolution (`git ls-remote`) runs here in the sandbox — the host never spawns git.\\n Failures are isolated per repo group and per skill so one bad repo/subdir doesn't prevent the\\n rest from being attempted; each failure is logged to stderr. Returns the number of skills that are\\n satisfied this run — freshly installed OR already present at the resolved pin (never raises).\\n The caller (run_git_download) compares this to the number requested and exits non-zero if any are\\n missing, so the overall behaviour is fail-closed.\\\"\\\"\\\"\\n SKILLS_ROOT.mkdir(parents=True, exist_ok=True)\\n\\n # Resolve mount refs → object ids, then skip skills already installed at that pin.\\n pending: list[tuple[GitSkill, str]] = []\\n satisfied = 0\\n for skill in git_skills:\\n try:\\n object_id = _resolve_object_id(skill.url, skill.ref, SKILLS_ROOT)\\n except GitSkillError as e:\\n print(f\\\"WARNING: {e} (skill: {skill.name})\\\", file=sys.stderr)\\n continue\\n if _git_skill_already_installed(state, skill, object_id):\\n satisfied += 1\\n else:\\n pending.append((skill, object_id))\\n\\n # Group pending by (repo, object id) so a repo shared by several skills is cloned once and its\\n # needed paths fetched together in a single sparse checkout.\\n skills_by_repo: dict[tuple[str, str], list[tuple[GitSkill, str]]] = {}\\n for skill, object_id in pending:\\n skills_by_repo.setdefault((skill.url, object_id), []).append((skill, object_id))\\n\\n for (url, object_id), group in skills_by_repo.items():\\n try:\\n with tempfile.TemporaryDirectory(\\n prefix=\\\".git-skill-dl-\\\",\\n dir=os.fspath(SKILLS_ROOT),\\n ignore_cleanup_errors=True,\\n ) as tmp_str:\\n repo_root = _git_fetch_repo(\\n url,\\n object_id,\\n [s.path for s, _ in group],\\n Path(tmp_str),\\n )\\n # Install each skill independently so a bad path only skips that one skill.\\n for skill, oid in group:\\n try:\\n _install_git_skill(repo_root, skill)\\n _mark_git_downloaded(state, skill, oid)\\n satisfied += 1\\n except GitSkillError as e:\\n print(f\\\"WARNING: {e}\\\", file=sys.stderr)\\n except GitSkillError as e:\\n # Collect (don't abort-on-first) so EVERY failing skill is reported before the run exits;\\n # run_git_download turns a non-zero failed count into a non-zero exit (fail-closed), which\\n # fails the whole agent request. We still don't purge a previously installed copy here, so\\n # a reused sandbox keeps the old files on disk even though this run is about to fail.\\n names = \\\", \\\".join(s.name for s, _ in group)\\n print(f\\\"WARNING: {e} (skills: {names})\\\", file=sys.stderr)\\n save_state(state)\\n return satisfied\\n\\n\\ndef run_git_download() -> None:\\n git_skills = git_skills_from_env(os.environ.get(\\\"AGENT_GIT_SKILLS\\\"))\\n state = load_state()\\n removed = reconcile_git_skills(git_skills, state)\\n save_state(state) # persist pruning even when there is nothing to download\\n\\n if not git_skills:\\n if removed:\\n print(f\\\"Removed {len(removed)} git skill(s) from disk. (AGENT_GIT_SKILLS empty.)\\\")\\n return\\n\\n satisfied = download_git_skills(git_skills, state)\\n failed = len(git_skills) - satisfied\\n suffix = f\\\" Removed {len(removed)} stale git skill(s).\\\" if removed else \\\"\\\"\\n if failed:\\n # Fail-closed: a requested skill that couldn't be installed exits non-zero so the gateway\\n # (ensureExecSuccess) fails the whole agent request, matching the fail-closed SKILL.md fetch.\\n # Per-skill WARNINGs above list exactly what failed.\\n sys.exit(\\n f\\\"Failed to install {failed}/{len(git_skills)} git skill(s); see warnings above.{suffix}\\\"\\n )\\n print(f\\\"Ensured {satisfied} git skill(s) (downloaded or already up to date).{suffix}\\\")\\n\\n\\ndef main() -> None:\\n run_git_download()\\n\\n\\nif __name__ == \\\"__main__\\\":\\n main()\\n\",\n} as const;\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AACO,IAAM,iBAAiB;AAAA,EAC5B,WAAW;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EACX,eAAe;AACjB;","names":[]}
1
+ {"version":3,"sources":["../../../src/core/sandbox/sandboxScripts.gen.ts"],"sourcesContent":["// AUTO-GENERATED by scripts/generate-sandbox-scripts.mjs — do not edit.\nexport const sandboxScripts = {\n mcpClient: \"#!/usr/bin/env python3\\n\\n# /// script\\n# dependencies = [\\\"mcp==1.29.0\\\", \\\"pydantic==2.12.5\\\", \\\"nats-py==2.15.0\\\"]\\n# ///\\n\\nimport os\\nimport sys\\nimport json\\nimport time\\nimport base64\\nimport asyncio\\nimport logging\\nimport argparse\\nfrom pathlib import Path\\nfrom typing import Any, TypedDict\\n\\nfrom pydantic import BaseModel, ValidationError\\n\\nfrom mcp.types import CallToolResult, TextContent, Tool\\n\\nlogger = logging.getLogger(__name__)\\n\\n_NATS_REQUEST_MAX_ATTEMPTS = 3\\n_NATS_RETRY_BACKOFF_MS = 250\\n\\n_nats_url = os.environ.get(\\\"TFY_NATS_URL\\\")\\n_nats_subject_prefix = os.environ.get(\\\"TFY_NATS_SUBJECT_PREFIX\\\")\\n_NATS_SUBJECT = f\\\"{_nats_subject_prefix}.mcp\\\" if _nats_url and _nats_subject_prefix else None\\nif bool(_nats_url) != bool(_nats_subject_prefix):\\n raise RuntimeError(\\n \\\"TFY_NATS_URL and TFY_NATS_SUBJECT_PREFIX must be set together; \\\"\\n f\\\"got TFY_NATS_URL={'set' if _nats_url else 'unset'}, \\\"\\n f\\\"TFY_NATS_SUBJECT_PREFIX={'set' if _nats_subject_prefix else 'unset'}\\\"\\n ) from None\\n\\n_nats_trace_headers: dict[str, str] = {}\\nif _traceparent := os.environ.get(\\\"TFY_TRACEPARENT\\\"):\\n _nats_trace_headers[\\\"traceparent\\\"] = _traceparent\\nif _tracestate := os.environ.get(\\\"TFY_TRACESTATE\\\"):\\n _nats_trace_headers[\\\"tracestate\\\"] = _tracestate\\n\\n\\nclass _ToolsCache(BaseModel):\\n fetched_at: float\\n tools: list[Tool]\\n\\n\\nclass _BridgeReply(BaseModel):\\n \\\"\\\"\\\"NATS bridge reply envelope. `result` is left untyped — operation-specific callers\\n (Tool / CallToolResult) validate the payload; this model only validates the envelope.\\\"\\\"\\\"\\n\\n ok: bool\\n result: Any = None\\n error: str = \\\"unknown error\\\"\\n source: str = \\\"internal\\\"\\n\\n\\nclass _ServerConfig(TypedDict):\\n allowed_tools: list[str]\\n\\n\\n_TOOLS_CACHE_TTL_SECONDS = 600\\n\\n_inflight_list_tools: dict[str, asyncio.Task[list[Tool]]] = {}\\n\\n_raw_servers = os.environ.get(\\\"TFY_MCP_SERVERS\\\")\\n_servers_map: dict[str, _ServerConfig] = json.loads(base64.b64decode(_raw_servers).decode()) if _raw_servers else {}\\n# Approvals are always enabled (Sandbox always injects TFY_ENABLE_AGENT_APPROVALS=true).\\n_enable_agent_approvals = os.environ.get(\\\"TFY_ENABLE_AGENT_APPROVALS\\\", \\\"true\\\").lower() == \\\"true\\\"\\n\\n\\ndef _check_tool_allowed(server: str, tool_name: str) -> None:\\n server_config = _servers_map.get(server)\\n if server_config is None:\\n raise RuntimeError(f\\\"Access denied: MCP server '{server}' is not available for this agent\\\") from None\\n server_tools = server_config.get(\\\"allowed_tools\\\") or []\\n if len(server_tools) > 0 and tool_name not in server_tools:\\n raise RuntimeError(f\\\"Access denied: tool '{tool_name}' is not enabled on server '{server}'\\\") from None\\n\\n\\ndef _cache_path(server: str) -> Path:\\n return Path(__file__).parent / f\\\"{server}.tools.json\\\"\\n\\n\\ndef _read_tools_cache(server: str) -> _ToolsCache | None:\\n p = _cache_path(server)\\n if not p.exists():\\n return None\\n try:\\n cache = _ToolsCache.model_validate_json(p.read_text(encoding=\\\"utf-8\\\"))\\n except Exception:\\n logger.exception(\\\"_ToolsCache.model_validate_json\\\")\\n p.unlink(missing_ok=True)\\n return None\\n if time.time() - cache.fetched_at > _TOOLS_CACHE_TTL_SECONDS:\\n p.unlink(missing_ok=True)\\n return None\\n return cache\\n\\n\\ndef _write_tools_cache(server: str, tools: list[Tool]) -> None:\\n try:\\n cache = _ToolsCache(fetched_at=time.time(), tools=tools)\\n _cache_path(server).write_text(cache.model_dump_json(), encoding=\\\"utf-8\\\")\\n except Exception:\\n logger.exception(\\\"_write_tools_cache\\\")\\n\\n\\ndef _require_nats_subject() -> str:\\n if not _NATS_SUBJECT or not _nats_url:\\n raise RuntimeError(\\n \\\"NATS bridge setup failed; NATS bridge is required for MCP from Code Mode. \\\"\\n \\\"Try again.\\\"\\n ) from None\\n return _NATS_SUBJECT\\n\\n\\nasync def _fetch_tools(server: str) -> list[Tool]:\\n result = await _nats_request(_require_nats_subject(), {\\\"op\\\": \\\"list_tools\\\", \\\"server\\\": server})\\n if not isinstance(result, dict):\\n raise RuntimeError(f\\\"NATS list_tools '{server}' returned unexpected shape: {result!r}\\\") from None\\n return [Tool.model_validate(t) for t in result.get(\\\"tools\\\", [])]\\n\\n\\nasync def _fetch_and_cache_tools(server: str) -> list[Tool]:\\n tools = await _fetch_tools(server)\\n _write_tools_cache(server, tools)\\n return tools\\n\\n\\nasync def _nats_request(subject: str, payload: dict[str, Any]) -> Any:\\n \\\"\\\"\\\"Send one request over a short-lived bridge connection, unwrap { ok, result|error }.\\n\\n Connection is per-call (closed in `finally`) to avoid leaking nats-py's aiohttp session.\\n Connect and request are retried in separate loops so a successful connection isn't torn\\n down and rebuilt on a request-level retry. Retries only failures that prove the request\\n never reached a handler (connect failure, NoRespondersError); timeouts and post-delivery\\n failures are not retried so a non-idempotent tool can't double-execute.\\n \\\"\\\"\\\"\\n import nats\\n from nats.errors import NoRespondersError\\n\\n data = json.dumps(payload).encode()\\n request_timeout = float(os.environ[\\\"TFY_CM_REQUEST_TIMEOUT_SECONDS\\\"])\\n\\n # Phase 1: establish a connection, retrying only connect failures.\\n nc = None\\n last_connect_error = \\\"unknown error\\\"\\n for attempt in range(1, _NATS_REQUEST_MAX_ATTEMPTS + 1):\\n if attempt > 1:\\n await asyncio.sleep(_NATS_RETRY_BACKOFF_MS / 1000)\\n try:\\n nc = await nats.connect(_nats_url)\\n break\\n except Exception as e:\\n last_connect_error = f\\\"connect to NATS at {_nats_url} failed: {e}\\\"\\n if nc is None:\\n raise RuntimeError(\\n f\\\"NATS connect failed for '{subject}' after {_NATS_REQUEST_MAX_ATTEMPTS} attempts: {last_connect_error}\\\"\\n ) from None\\n\\n # Phase 2: request over the established connection, retrying only NoRespondersError.\\n try:\\n last_transport_error = \\\"unknown error\\\"\\n for attempt in range(1, _NATS_REQUEST_MAX_ATTEMPTS + 1):\\n if attempt > 1:\\n await asyncio.sleep(_NATS_RETRY_BACKOFF_MS / 1000)\\n try:\\n msg = await nc.request(\\n subject,\\n data,\\n timeout=request_timeout,\\n headers=_nats_trace_headers or None,\\n )\\n except NoRespondersError as e:\\n # Server confirms no subscriber — never delivered, safe to retry.\\n last_transport_error = f\\\"no NATS responder for '{subject}': {e}\\\"\\n continue\\n except Exception as e:\\n # Timeout / other: the tool may already have run, so we must not retry.\\n raise RuntimeError(f\\\"NATS request '{subject}' failed: {e}\\\") from None\\n try:\\n reply = _BridgeReply.model_validate_json(msg.data.decode())\\n except ValidationError as e:\\n raise RuntimeError(f\\\"NATS reply on '{subject}' is malformed: {e}\\\") from None\\n if not reply.ok:\\n # A well-formed reply means the transport worked; `source` says who's at fault.\\n if reply.source == \\\"caller\\\":\\n raise RuntimeError(f\\\"Invalid MCP request on '{subject}': {reply.error}\\\") from None\\n if reply.source == \\\"transport\\\":\\n raise RuntimeError(f\\\"Code Mode transport error on '{subject}': {reply.error}\\\") from None\\n raise RuntimeError(f\\\"Internal MCP error on '{subject}': {reply.error}\\\") from None\\n return reply.result\\n raise RuntimeError(\\n f\\\"NATS bridge has no responder for '{subject}' after {_NATS_REQUEST_MAX_ATTEMPTS} attempts: {last_transport_error}\\\"\\n ) from None\\n finally:\\n try:\\n await nc.close()\\n except Exception:\\n logger.debug(\\\"Error closing NATS connection\\\", exc_info=True)\\n\\n\\nasync def _get_tools(server: str) -> list[Tool]:\\n cache = _read_tools_cache(server)\\n if cache is not None:\\n return cache.tools\\n task = _inflight_list_tools.get(server)\\n if task is None:\\n task = asyncio.create_task(_fetch_and_cache_tools(server))\\n _inflight_list_tools[server] = task\\n task.add_done_callback(lambda _t: _inflight_list_tools.pop(server, None))\\n return await task\\n\\n\\nasync def _get_tool(server: str, tool_name: str) -> Tool | None:\\n for t in await _get_tools(server):\\n if t.name == tool_name:\\n return t\\n return None\\n\\n\\ndef _is_destructive(tool: Tool) -> bool:\\n annotations = tool.annotations\\n if annotations is None:\\n return False\\n return bool(annotations.destructiveHint) or (not annotations.readOnlyHint and annotations.readOnlyHint is not None)\\n\\nasync def _ensure_non_destructive(server: str, tool_name: str) -> None:\\n tool = await _get_tool(server, tool_name)\\n if tool is None:\\n raise RuntimeError(f\\\"Tool '{tool_name}' not found on MCP server '{server}'\\\") from None\\n if _is_destructive(tool):\\n raise RuntimeError(\\n f\\\"Tool '{tool_name}' on MCP server '{server}' is destructive and cannot be called in Code Mode; \\\"\\n f\\\"call it directly so it can go through the user approval flow\\\"\\n ) from None\\n\\n\\ndef _project_call_tool_result(server: str, tool: str, result: CallToolResult) -> Any:\\n \\\"\\\"\\\"Project an MCP-wire CallToolResult into the user-facing Python value.\\\"\\\"\\\"\\n if result.isError:\\n text_parts = [c.text for c in result.content if isinstance(c, TextContent) and c.text]\\n msg = \\\"; \\\".join(text_parts) if text_parts else \\\"tool returned an error\\\"\\n raise RuntimeError(f\\\"MCP tool error (server={server}, tool={tool}): {msg}\\\") from None\\n\\n if result.structuredContent is not None:\\n return result.structuredContent\\n\\n # Fallback to content blocks (tools without outputSchema / plain text responses)\\n if len(result.content) == 1:\\n first = result.content[0]\\n if isinstance(first, TextContent) and first.text:\\n try:\\n return json.loads(first.text)\\n except Exception:\\n pass\\n if result.content:\\n return result.content\\n return None\\n\\n\\nasync def call_tool(server: str, tool: str, body: dict[str, Any]) -> Any:\\n _check_tool_allowed(server, tool)\\n if _enable_agent_approvals:\\n await _ensure_non_destructive(server, tool)\\n\\n raw = await _nats_request(\\n _require_nats_subject(),\\n {\\\"op\\\": \\\"call_tool\\\", \\\"server\\\": server, \\\"tool\\\": tool, \\\"arguments\\\": body},\\n )\\n try:\\n result = CallToolResult.model_validate(raw)\\n except Exception as e:\\n raise RuntimeError(f\\\"NATS call_tool reply for '{server}/{tool}' is malformed: {e}\\\") from None\\n\\n return _project_call_tool_result(server, tool, result)\\n\\n\\n_USAGE = \\\"mcp_client.py call-tool <server> <tool> <args-json>\\\"\\n\\n\\ndef _build_arg_parser() -> argparse.ArgumentParser:\\n parser = argparse.ArgumentParser(prog=\\\"mcp_client.py\\\", usage=_USAGE)\\n sub = parser.add_subparsers(dest=\\\"cmd\\\", required=True)\\n\\n call_tool_p = sub.add_parser(\\\"call-tool\\\", help=\\\"Invoke an MCP tool\\\")\\n call_tool_p.add_argument(\\\"server\\\")\\n call_tool_p.add_argument(\\\"tool\\\")\\n # Parsing JSON at the argparse boundary surfaces malformed input as a clean\\n # argparse error (with usage) instead of an opaque traceback from inside the handler.\\n call_tool_p.add_argument(\\\"args_json\\\", metavar=\\\"args-json\\\", type=json.loads)\\n\\n return parser\\n\\n\\nasync def _main() -> None:\\n args = _build_arg_parser().parse_args()\\n try:\\n if args.cmd == \\\"call-tool\\\":\\n result = await call_tool(args.server, args.tool, args.args_json)\\n print(json.dumps(result, default=str))\\n except RuntimeError as e:\\n sys.exit(str(e))\\n\\n\\nif __name__ == \\\"__main__\\\":\\n asyncio.run(_main())\\n\",\n gitDownloader: \"#!/usr/bin/env python3\\n\\n# /// script\\n# dependencies = [\\\"pydantic==2.12.5\\\"]\\n# ///\\n\\n\\\"\\\"\\\"Materialize git skill directories into TFY_SKILLS_DIR (default /opt/tfy/skills). Requires `git`.\\n\\nEach git skill (AGENT_GIT_SKILLS) is materialized with a blob-filtered sparse `git` clone that\\nfetches only the requested subdir at a resolved object id, keyed by name. Mount `ref` values\\n(branch, tag, or full object id) are resolved here via `git ls-remote` — the host never spawns\\ngit. A single on-disk state file records the installed object id + subdir per name so a skill\\nwhose tip hasn't moved is skipped instead of re-cloning every run, and skills no longer desired\\nare pruned from disk. A sparse clone is used instead of a full repo tarball because its cost is\\n~constant in the subdir size rather than the whole-repo size, which is dramatically faster for a\\nsmall skill living in a large monorepo.\\n\\nOptional env:\\n - AGENT_GIT_SKILLS (base64-encoded JSON list of {name, url, path, ref}; empty\\n clears git downloads). `ref` is a branch, tag, or full object id.\\n - TFY_SKILLS_DIR: override the skills directory (default /opt/tfy/skills).\\n\\\"\\\"\\\"\\n\\nfrom __future__ import annotations\\n\\nimport base64\\nimport json\\nimport os\\nimport re\\nimport shutil\\nimport subprocess\\nimport sys\\nimport tempfile\\nfrom pathlib import Path\\n\\nfrom pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator\\n\\nDEFAULT_SKILLS_DIR = \\\"/opt/tfy/skills\\\"\\nSTATE_FILE_NAME = \\\".tfy-skill-downloader-state.json\\\"\\n# Per-git-invocation wall-clock cap so a hung/slow fetch can't stall sandbox init indefinitely.\\nGIT_CLONE_TIMEOUT_SECONDS = 120\\n# Cap the installed skill size so a huge repo-root skill can't fill the persistent skills dir. The\\n# sparse clone already bounds a subdir skill to its subdir; this also guards the whole-repo (root\\n# subdir) case where checkout hydrates every file.\\nGIT_SKILL_MAX_BYTES = 200 * 1024 * 1024 # 200MB installed\\n# Resolved object id used for fetch + skip state (sha1 / sha256).\\nOBJECT_ID_RE = re.compile(r\\\"^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$\\\")\\n# Mount ref (branch/tag/SHA) charset — must match host AgentSpec SkillMount.ref validation.\\n# Validated before the value is passed to `git ls-remote` / used as a fetch arg fallback.\\nGIT_REF_RE = re.compile(r\\\"^[A-Za-z0-9._\\\\-/]+$\\\")\\nSKILLS_ROOT = Path(os.environ.get(\\\"TFY_SKILLS_DIR\\\", DEFAULT_SKILLS_DIR))\\nSTATE_PATH = SKILLS_ROOT / STATE_FILE_NAME\\n\\n\\nclass GitSkillError(Exception):\\n \\\"\\\"\\\"Recoverable per-repo/per-skill git failure. Raised (not sys.exit) so a single bad repo or\\n subdirectory doesn't abort the remaining repository groups (best-effort failure isolation).\\\"\\\"\\\"\\n\\n\\ndef skill_dir(name: str) -> Path | None:\\n \\\"\\\"\\\"Resolve a skill's on-disk directory. This is the ONLY place a skill name becomes a path, so\\n the safety check here is what guarantees that no name — freshly requested or read back from the\\n (possibly corrupt) state file — can escape SKILLS_ROOT during delete/copy.\\n\\n Returns None for an unsafe name (empty, absolute, containing a path separator, \\\".\\\" / \\\"..\\\", or the\\n reserved state-file name) so each caller can skip it per its own failure contract instead of\\n touching an out-of-tree path. The state-file name is reserved because a skill whose name equals it\\n would resolve to STATE_PATH and let a skill install clobber the reconciliation state.\\n \\\"\\\"\\\"\\n if not name or name in (\\\".\\\", \\\"..\\\") or \\\"/\\\" in name or \\\"\\\\\\\\\\\" in name or name == STATE_FILE_NAME:\\n return None\\n return SKILLS_ROOT / name\\n\\n\\nclass GitSkill(BaseModel):\\n \\\"\\\"\\\"A git skill to materialize: `name`, `url`, `path`, and mount `ref` (branch, tag, or object id).\\n This script resolves `ref` via `git ls-remote` in the sandbox, then sparse-fetches the resolved\\n object id. The url and ref are format-validated here (defense in depth) so an unexpected value\\n can't be interpreted as a git option or reach a non-public host.\\\"\\\"\\\"\\n\\n model_config = ConfigDict(extra=\\\"ignore\\\")\\n\\n name: str\\n url: str\\n path: str = \\\"\\\"\\n # Branch, tag, or full object id from the agent_spec mount (resolved in-sandbox before fetch).\\n ref: str\\n\\n @field_validator(\\\"url\\\")\\n @classmethod\\n def _validate_url(cls, v: str) -> str:\\n # Only the two public hosts the gateway supports. Anchoring on the \\\"https://<host>/\\\" prefix\\n # blocks non-https schemes (file://, ext::, ssh) and any other host, so this URL can't be\\n # turned into an SSRF/local-file/command vector once it reaches `git`.\\n if not (v.startswith(\\\"https://github.com/\\\") or v.startswith(\\\"https://gitlab.com/\\\")):\\n raise ValueError(f\\\"git skill url must be a github.com/gitlab.com https URL: {v!r}\\\")\\n return v\\n\\n @field_validator(\\\"ref\\\")\\n @classmethod\\n def _validate_ref(cls, v: str) -> str:\\n if not v or \\\"..\\\" in v.split(\\\"/\\\") or not GIT_REF_RE.match(v):\\n raise ValueError(f\\\"git skill ref is invalid: {v!r}\\\")\\n if v.replace(\\\"/\\\", \\\"\\\") == \\\"\\\":\\n raise ValueError(f\\\"git skill ref must not consist only of slashes: {v!r}\\\")\\n return v\\n\\n @field_validator(\\\"path\\\")\\n @classmethod\\n def _validate_path(cls, v: str) -> str:\\n # Relative subpath within the repo. Reject absolute paths and \\\"..\\\" segments so it can't\\n # escape the repo root. Leading/trailing slashes are stripped (repo root == \\\"\\\"). A leading\\n # \\\"-\\\" is safe because subdirs are fed to `git sparse-checkout set` via stdin, never as args.\\n if v.startswith(\\\"/\\\"):\\n raise ValueError(f\\\"git skill path must be relative: {v!r}\\\")\\n stripped = v.strip(\\\"/\\\")\\n if stripped and \\\"..\\\" in stripped.split(\\\"/\\\"):\\n raise ValueError(f\\\"git skill path must not contain '..': {v!r}\\\")\\n return stripped\\n\\n\\nclass DownloadedGitSkill(BaseModel):\\n \\\"\\\"\\\"A git skill recorded on disk: its dir name, the object id it was installed at, and the repo\\n path it was installed from. The ref lets a later run skip re-downloading when the requested\\n pin is unchanged; the path is part of the identity so that changing the path at the same\\n pin still triggers a re-download instead of serving the previously-installed layout.\\\"\\\"\\\"\\n\\n model_config = ConfigDict(extra=\\\"ignore\\\")\\n\\n name: str\\n # Empty when migrated from a pre-pin-tracking state file — treated as \\\"unknown\\\", forcing a\\n # one-time re-download while still keeping the name tracked for pruning.\\n ref: str = \\\"\\\"\\n # Repo path the skill was installed from (repo root == \\\"\\\"). Empty on entries written before\\n # path tracking; a mismatch against the requested path forces a one-time re-download.\\n path: str = \\\"\\\"\\n\\n\\nclass SkillDownloaderState(BaseModel):\\n \\\"\\\"\\\"Persisted next to skills; tracks what was downloaded so undesired skills can be pruned.\\\"\\\"\\\"\\n\\n model_config = ConfigDict(extra=\\\"ignore\\\")\\n\\n downloaded_git_skills: list[DownloadedGitSkill] = Field(\\n default_factory=list,\\n description=\\\"Git skills successfully downloaded (name + installed object id).\\\",\\n )\\n\\n\\ndef _migrate_downloaded_git_skills(entries: list[object]) -> list[dict[str, object]]:\\n \\\"\\\"\\\"Normalize state entries: rename legacy `commit_sha` → `ref` and `subdir` → `path`.\\\"\\\"\\\"\\n migrated: list[dict[str, object]] = []\\n for item in entries:\\n if not isinstance(item, dict):\\n continue\\n entry = dict(item)\\n if \\\"ref\\\" not in entry and isinstance(entry.get(\\\"commit_sha\\\"), str):\\n entry[\\\"ref\\\"] = entry[\\\"commit_sha\\\"]\\n entry.pop(\\\"commit_sha\\\", None)\\n if \\\"path\\\" not in entry and isinstance(entry.get(\\\"subdir\\\"), str):\\n entry[\\\"path\\\"] = entry[\\\"subdir\\\"]\\n entry.pop(\\\"subdir\\\", None)\\n migrated.append(entry)\\n return migrated\\n\\n\\ndef load_state() -> SkillDownloaderState:\\n if not STATE_PATH.is_file():\\n return SkillDownloaderState()\\n try:\\n raw = json.loads(STATE_PATH.read_text(encoding=\\\"utf-8\\\"))\\n except (OSError, ValueError):\\n return SkillDownloaderState()\\n if not isinstance(raw, dict):\\n return SkillDownloaderState()\\n # Migrate a pre-pin-tracking state file: its `downloaded_git_names` (list of names, no pin)\\n # becomes entries with an empty ref. That keeps the names tracked so undesired skills are\\n # still pruned, and forces a one-time re-download (empty != any requested pin). Skipped when\\n # the new field is already present so we never clobber real pins.\\n if \\\"downloaded_git_skills\\\" not in raw and isinstance(raw.get(\\\"downloaded_git_names\\\"), list):\\n raw[\\\"downloaded_git_skills\\\"] = [\\n {\\\"name\\\": n, \\\"ref\\\": \\\"\\\"} for n in raw[\\\"downloaded_git_names\\\"] if isinstance(n, str)\\n ]\\n if isinstance(raw.get(\\\"downloaded_git_skills\\\"), list):\\n raw[\\\"downloaded_git_skills\\\"] = _migrate_downloaded_git_skills(raw[\\\"downloaded_git_skills\\\"])\\n try:\\n return SkillDownloaderState.model_validate(raw)\\n except ValidationError:\\n return SkillDownloaderState()\\n\\n\\ndef save_state(state: SkillDownloaderState) -> None:\\n STATE_PATH.parent.mkdir(parents=True, exist_ok=True)\\n tmp = STATE_PATH.with_name(STATE_PATH.name + \\\".tmp\\\")\\n tmp.write_text(state.model_dump_json(indent=2), encoding=\\\"utf-8\\\")\\n os.replace(tmp, STATE_PATH)\\n\\n\\ndef _rmtree(path: Path, ignore_errors: bool = True) -> None:\\n try:\\n shutil.rmtree(path, ignore_errors=ignore_errors)\\n except OSError:\\n pass\\n\\n\\ndef _delete_skill_dir_by_name(name: str, label: str) -> None:\\n \\\"\\\"\\\"Fail-closed prune of a no-longer-desired skill's directory: abort the whole run if removal\\n fails so we never silently leave a stale skill on disk. skill_dir() returns None for a\\n corrupt/tampered name, so rmtree never runs on an out-of-tree path.\\\"\\\"\\\"\\n dir_ = skill_dir(name)\\n if dir_ is None or not dir_.is_dir():\\n return\\n try:\\n shutil.rmtree(dir_)\\n except OSError as e:\\n sys.exit(f\\\"Could not remove skill directory {dir_} ({label}): {e}\\\")\\n\\n\\ndef git_skills_from_env(raw: str | None) -> list[GitSkill]:\\n \\\"\\\"\\\"Empty or missing env means \\\"no desired git skills\\\" (prune all git downloads).\\\"\\\"\\\"\\n if raw is None or not str(raw).strip():\\n return []\\n try:\\n data = json.loads(base64.b64decode(raw))\\n except (ValueError, json.JSONDecodeError) as e:\\n sys.exit(f\\\"AGENT_GIT_SKILLS is not valid base64-encoded JSON: {e}\\\")\\n if not isinstance(data, list):\\n sys.exit(\\\"AGENT_GIT_SKILLS must be a JSON list.\\\")\\n try:\\n return [GitSkill.model_validate(item) for item in data]\\n except ValidationError as e:\\n sys.exit(f\\\"Invalid AGENT_GIT_SKILLS entry: {e}\\\")\\n\\n\\ndef reconcile_git_skills(git_skills: list[GitSkill], state: SkillDownloaderState) -> list[str]:\\n \\\"\\\"\\\"Delete dirs for git skills no longer desired; prune them from state. Returns removed names.\\\"\\\"\\\"\\n desired_names = {g.name for g in git_skills}\\n removed: list[str] = []\\n for entry in state.downloaded_git_skills:\\n if entry.name in desired_names:\\n continue\\n # Fail-closed prune: a failed delete aborts the run rather than leaving a stale skill on disk.\\n # The name is dropped from tracking either way.\\n _delete_skill_dir_by_name(entry.name, entry.name)\\n removed.append(entry.name)\\n state.downloaded_git_skills = [\\n e for e in state.downloaded_git_skills if e.name in desired_names\\n ]\\n return removed\\n\\n\\ndef _run_git(\\n args: list[str], cwd: Path, input_text: str | None = None\\n) -> subprocess.CompletedProcess[str]:\\n \\\"\\\"\\\"Run a git subcommand non-interactively and fail-closed on error.\\n\\n Args are passed as a list (never a shell string) and git runs with a scrubbed environment:\\n ambient global/system config and credential helpers are ignored (so nothing can rewrite the URL\\n via insteadOf or inject creds) and prompting is disabled (so a private/typo repo fails fast\\n instead of hanging on a username prompt).\\\"\\\"\\\"\\n env = {\\n **os.environ,\\n \\\"GIT_TERMINAL_PROMPT\\\": \\\"0\\\", # never prompt for credentials — fail fast on a non-public repo\\n \\\"GIT_CONFIG_NOSYSTEM\\\": \\\"1\\\", # ignore /etc/gitconfig\\n \\\"GIT_CONFIG_GLOBAL\\\": \\\"/dev/null\\\", # ignore ~/.gitconfig (insteadOf rewrites, cred helpers)\\n \\\"GCM_INTERACTIVE\\\": \\\"never\\\",\\n }\\n try:\\n proc = subprocess.run(\\n [\\\"git\\\", *args],\\n cwd=os.fspath(cwd),\\n env=env,\\n input=input_text,\\n capture_output=True,\\n text=True,\\n timeout=GIT_CLONE_TIMEOUT_SECONDS,\\n )\\n except subprocess.TimeoutExpired:\\n raise GitSkillError(f\\\"git {args[0]} timed out after {GIT_CLONE_TIMEOUT_SECONDS}s\\\")\\n except OSError as e:\\n raise GitSkillError(f\\\"could not run git (is it installed in the sandbox?): {e}\\\")\\n if proc.returncode != 0:\\n detail = (proc.stderr or proc.stdout or \\\"\\\").strip()[-500:]\\n raise GitSkillError(f\\\"git {args[0]} failed (exit {proc.returncode}): {detail}\\\")\\n return proc\\n\\n\\ndef _parse_ls_remote_object_id(stdout: str) -> str | None:\\n \\\"\\\"\\\"Prefer a peeled `ref^{}` line (annotated tags), else the first object id.\\\"\\\"\\\"\\n first: str | None = None\\n for line in stdout.splitlines():\\n trimmed = line.strip()\\n if not trimmed:\\n continue\\n parts = trimmed.split(None, 1)\\n sha = parts[0]\\n name = parts[1] if len(parts) > 1 else \\\"\\\"\\n if name.endswith(\\\"^{}\\\"):\\n return sha\\n if first is None:\\n first = sha\\n return first\\n\\n\\ndef _resolve_object_id(url: str, ref: str, cwd: Path) -> str:\\n \\\"\\\"\\\"Resolve a mount ref (branch/tag/SHA) to an object id inside the sandbox.\\n\\n Full object ids are used as-is (`git ls-remote` does not advertise bare SHAs on\\n GitHub/GitLab). Branches and tags are resolved via `git ls-remote` so a branch tip\\n advances on later sandbox inits.\\n \\\"\\\"\\\"\\n if OBJECT_ID_RE.match(ref):\\n return ref\\n proc = _run_git([\\\"ls-remote\\\", url, ref], cwd=cwd)\\n oid = _parse_ls_remote_object_id(proc.stdout or \\\"\\\")\\n if oid is None:\\n raise GitSkillError(f\\\"git ls-remote returned no commit for {url} ref {ref}\\\")\\n return oid\\n\\n\\ndef _git_fetch_repo(\\n url: str, object_id: str, paths: list[str], parent_dir: Path\\n) -> Path:\\n \\\"\\\"\\\"Object-id-pinned, shallow, blob-filtered sparse checkout of `paths` into a fresh repo under\\n `parent_dir`, returning the repo working directory.\\n\\n When every requested path is the repo root (\\\"\\\") we can't sparse-restrict, so we do a full\\n shallow checkout (all blobs). Otherwise we cone-sparse to just the requested paths and use\\n --filter=blob:none so only those paths' blobs are fetched — the whole point of the approach.\\n Paths are fed to `git sparse-checkout set` via stdin so a path value can never be read as an\\n option/argument. Fetching by object id relies on the server allowing it, which github.com and\\n gitlab.com both do.\\\"\\\"\\\"\\n repo = parent_dir / \\\"repo\\\"\\n _run_git([\\\"init\\\", \\\"-q\\\", os.fspath(repo)], cwd=parent_dir)\\n _run_git([\\\"remote\\\", \\\"add\\\", \\\"origin\\\", url], cwd=repo)\\n\\n cone_dirs = [s for s in dict.fromkeys(paths) if s] # de-duped, root (\\\"\\\") dropped\\n want_full_tree = any(s == \\\"\\\" for s in paths)\\n if not want_full_tree and cone_dirs:\\n _run_git([\\\"sparse-checkout\\\", \\\"init\\\", \\\"--cone\\\"], cwd=repo)\\n _run_git(\\n [\\\"sparse-checkout\\\", \\\"set\\\", \\\"--stdin\\\"],\\n cwd=repo,\\n input_text=\\\"\\\".join(f\\\"{d}\\\\n\\\" for d in cone_dirs),\\n )\\n\\n fetch_args = [\\\"-c\\\", \\\"protocol.file.allow=never\\\", \\\"fetch\\\", \\\"-q\\\", \\\"--depth\\\", \\\"1\\\"]\\n if not want_full_tree:\\n fetch_args.append(\\\"--filter=blob:none\\\")\\n fetch_args += [\\\"origin\\\", object_id]\\n _run_git(fetch_args, cwd=repo)\\n\\n _run_git(\\n [\\\"-c\\\", \\\"advice.detachedHead=false\\\", \\\"checkout\\\", \\\"-q\\\", \\\"--detach\\\", \\\"FETCH_HEAD\\\"], cwd=repo\\n )\\n return repo\\n\\n\\ndef _installed_size_bytes(path: Path) -> int:\\n \\\"\\\"\\\"Total size of regular files under `path`, excluding the repo's .git dir and without following\\n symlinks (they're copied as links, not their targets), used to enforce GIT_SKILL_MAX_BYTES.\\\"\\\"\\\"\\n total = 0\\n for root, dirs, files in os.walk(path, followlinks=False):\\n if \\\".git\\\" in dirs:\\n dirs.remove(\\\".git\\\")\\n for name in files:\\n try:\\n total += (Path(root) / name).lstat().st_size\\n except OSError:\\n continue\\n return total\\n\\n\\ndef _install_git_skill(repo_root: Path, skill: GitSkill) -> None:\\n dest = skill_dir(skill.name)\\n if dest is None:\\n raise GitSkillError(f\\\"Git skill: unsafe directory name {skill.name!r}\\\")\\n repo_root_resolved = repo_root.resolve()\\n src = (repo_root / skill.path).resolve() if skill.path else repo_root_resolved\\n # Defense in depth: even though path is validated, ensure the resolved source stays within the\\n # cloned repo (guards against a symlinked directory component pointing outside the checkout).\\n if src != repo_root_resolved and not src.is_relative_to(repo_root_resolved):\\n raise GitSkillError(\\n f\\\"Git skill {skill.name}: path '{skill.path}' escapes the repository\\\"\\n )\\n if not src.is_dir():\\n raise GitSkillError(\\n f\\\"Git skill {skill.name}: path '{skill.path}' not found in repository\\\"\\n )\\n if _installed_size_bytes(src) > GIT_SKILL_MAX_BYTES:\\n raise GitSkillError(\\n f\\\"Git skill {skill.name}: content exceeds {GIT_SKILL_MAX_BYTES} bytes\\\"\\n )\\n # Stage the fresh copy into a temp sibling dir, then swap it in with an atomic rename. `dest` is\\n # only touched once the copy has fully succeeded, so a failed/partial copytree can never leave\\n # the skill missing (which would contradict the path the gateway advertises in the prompt) — the\\n # previously installed copy stays intact on failure. `.git` is excluded (only present for a repo-\\n # root skill) and symlinks are copied as links, never followed, so a link inside the repo can't\\n # pull external file contents into the skill dir at copy time.\\n staging = dest.with_name(f\\\".{dest.name}.new-{os.getpid()}\\\")\\n _rmtree(staging) # clear any leftover staging dir from an earlier aborted run\\n try:\\n shutil.copytree(src, staging, symlinks=True, ignore=shutil.ignore_patterns(\\\".git\\\"))\\n except (OSError, shutil.Error) as e:\\n # shutil.Error (a multi-file copy error, NOT an OSError subclass) must be caught here too so a\\n # bad copy is reported as a per-skill GitSkillError (WARNING + counted failure) instead of an\\n # uncaught traceback that aborts the whole run and skips save_state for the group.\\n _rmtree(staging)\\n raise GitSkillError(f\\\"Failed to stage git skill {skill.name}: {e}\\\")\\n try:\\n if dest.exists():\\n shutil.rmtree(dest)\\n # Same filesystem (both under SKILLS_ROOT), so this is an atomic rename; the window where the\\n # skill dir is absent shrinks from a full copy to a single near-instant rename.\\n os.replace(staging, dest)\\n except OSError as e:\\n _rmtree(staging)\\n raise GitSkillError(f\\\"Failed to install git skill {skill.name}: {e}\\\")\\n\\n\\ndef _mark_git_downloaded(\\n state: SkillDownloaderState, skill: GitSkill, object_id: str\\n) -> None:\\n \\\"\\\"\\\"Upsert a git skill's installed object id + path so the next run can skip when both match.\\\"\\\"\\\"\\n for entry in state.downloaded_git_skills:\\n if entry.name == skill.name:\\n entry.ref = object_id\\n entry.path = skill.path\\n return\\n state.downloaded_git_skills.append(\\n DownloadedGitSkill(name=skill.name, ref=object_id, path=skill.path)\\n )\\n\\n\\ndef _git_skill_already_installed(\\n state: SkillDownloaderState, skill: GitSkill, object_id: str\\n) -> bool:\\n \\\"\\\"\\\"True only when the skill is recorded at the resolved object id AND path AND its directory is\\n still present. Re-download otherwise (branch tip moved, path changed, first install, or the\\n dir was removed out-of-band) so the prompt never points at a missing/stale skill dir.\\\"\\\"\\\"\\n dir_ = skill_dir(skill.name)\\n if dir_ is None or not dir_.is_dir():\\n return False\\n return any(\\n e.name == skill.name and e.ref == object_id and e.path == skill.path\\n for e in state.downloaded_git_skills\\n )\\n\\n\\ndef download_git_skills(\\n git_skills: list[GitSkill], state: SkillDownloaderState\\n) -> int:\\n \\\"\\\"\\\"Ensure desired git skills are installed at their resolved object id, skipping any already\\n present at that pin so an unchanged tip no longer re-clones every run.\\n\\n Ref resolution (`git ls-remote`) runs here in the sandbox — the host never spawns git.\\n Failures are isolated per repo group and per skill so one bad repo/subdir doesn't prevent the\\n rest from being attempted; each failure is logged to stderr. Returns the number of skills that are\\n satisfied this run — freshly installed OR already present at the resolved pin (never raises).\\n The caller (run_git_download) compares this to the number requested and exits non-zero if any are\\n missing, so the overall behaviour is fail-closed.\\\"\\\"\\\"\\n SKILLS_ROOT.mkdir(parents=True, exist_ok=True)\\n\\n # Resolve mount refs → object ids, then skip skills already installed at that pin.\\n pending: list[tuple[GitSkill, str]] = []\\n satisfied = 0\\n for skill in git_skills:\\n try:\\n object_id = _resolve_object_id(skill.url, skill.ref, SKILLS_ROOT)\\n except GitSkillError as e:\\n print(f\\\"WARNING: {e} (skill: {skill.name})\\\", file=sys.stderr)\\n continue\\n if _git_skill_already_installed(state, skill, object_id):\\n satisfied += 1\\n else:\\n pending.append((skill, object_id))\\n\\n # Group pending by (repo, object id) so a repo shared by several skills is cloned once and its\\n # needed paths fetched together in a single sparse checkout.\\n skills_by_repo: dict[tuple[str, str], list[tuple[GitSkill, str]]] = {}\\n for skill, object_id in pending:\\n skills_by_repo.setdefault((skill.url, object_id), []).append((skill, object_id))\\n\\n for (url, object_id), group in skills_by_repo.items():\\n try:\\n with tempfile.TemporaryDirectory(\\n prefix=\\\".git-skill-dl-\\\",\\n dir=os.fspath(SKILLS_ROOT),\\n ignore_cleanup_errors=True,\\n ) as tmp_str:\\n repo_root = _git_fetch_repo(\\n url,\\n object_id,\\n [s.path for s, _ in group],\\n Path(tmp_str),\\n )\\n # Install each skill independently so a bad path only skips that one skill.\\n for skill, oid in group:\\n try:\\n _install_git_skill(repo_root, skill)\\n _mark_git_downloaded(state, skill, oid)\\n satisfied += 1\\n except GitSkillError as e:\\n print(f\\\"WARNING: {e}\\\", file=sys.stderr)\\n except GitSkillError as e:\\n # Collect (don't abort-on-first) so EVERY failing skill is reported before the run exits;\\n # run_git_download turns a non-zero failed count into a non-zero exit (fail-closed), which\\n # fails the whole agent request. We still don't purge a previously installed copy here, so\\n # a reused sandbox keeps the old files on disk even though this run is about to fail.\\n names = \\\", \\\".join(s.name for s, _ in group)\\n print(f\\\"WARNING: {e} (skills: {names})\\\", file=sys.stderr)\\n save_state(state)\\n return satisfied\\n\\n\\ndef run_git_download() -> None:\\n git_skills = git_skills_from_env(os.environ.get(\\\"AGENT_GIT_SKILLS\\\"))\\n state = load_state()\\n removed = reconcile_git_skills(git_skills, state)\\n save_state(state) # persist pruning even when there is nothing to download\\n\\n if not git_skills:\\n if removed:\\n print(f\\\"Removed {len(removed)} git skill(s) from disk. (AGENT_GIT_SKILLS empty.)\\\")\\n return\\n\\n satisfied = download_git_skills(git_skills, state)\\n failed = len(git_skills) - satisfied\\n suffix = f\\\" Removed {len(removed)} stale git skill(s).\\\" if removed else \\\"\\\"\\n if failed:\\n # Fail-closed: a requested skill that couldn't be installed exits non-zero so the gateway\\n # (ensureExecSuccess) fails the whole agent request, matching the fail-closed SKILL.md fetch.\\n # Per-skill WARNINGs above list exactly what failed.\\n sys.exit(\\n f\\\"Failed to install {failed}/{len(git_skills)} git skill(s); see warnings above.{suffix}\\\"\\n )\\n print(f\\\"Ensured {satisfied} git skill(s) (downloaded or already up to date).{suffix}\\\")\\n\\n\\ndef main() -> None:\\n run_git_download()\\n\\n\\nif __name__ == \\\"__main__\\\":\\n main()\\n\",\n} as const;\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AACO,IAAM,iBAAiB;AAAA,EAC5B,WAAW;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EACX,eAAe;AACjB;","names":[]}
@@ -3,7 +3,7 @@ var sandboxScripts = {
3
3
  mcpClient: `#!/usr/bin/env python3
4
4
 
5
5
  # /// script
6
- # dependencies = ["pydantic==2.12.5", "nats-py==2.15.0"]
6
+ # dependencies = ["mcp==1.29.0", "pydantic==2.12.5", "nats-py==2.15.0"]
7
7
  # ///
8
8
 
9
9
  import os
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/core/sandbox/sandboxScripts.gen.ts"],"sourcesContent":["// AUTO-GENERATED by scripts/generate-sandbox-scripts.mjs — do not edit.\nexport const sandboxScripts = {\n mcpClient: \"#!/usr/bin/env python3\\n\\n# /// script\\n# dependencies = [\\\"pydantic==2.12.5\\\", \\\"nats-py==2.15.0\\\"]\\n# ///\\n\\nimport os\\nimport sys\\nimport json\\nimport time\\nimport base64\\nimport asyncio\\nimport logging\\nimport argparse\\nfrom pathlib import Path\\nfrom typing import Any, TypedDict\\n\\nfrom pydantic import BaseModel, ValidationError\\n\\nfrom mcp.types import CallToolResult, TextContent, Tool\\n\\nlogger = logging.getLogger(__name__)\\n\\n_NATS_REQUEST_MAX_ATTEMPTS = 3\\n_NATS_RETRY_BACKOFF_MS = 250\\n\\n_nats_url = os.environ.get(\\\"TFY_NATS_URL\\\")\\n_nats_subject_prefix = os.environ.get(\\\"TFY_NATS_SUBJECT_PREFIX\\\")\\n_NATS_SUBJECT = f\\\"{_nats_subject_prefix}.mcp\\\" if _nats_url and _nats_subject_prefix else None\\nif bool(_nats_url) != bool(_nats_subject_prefix):\\n raise RuntimeError(\\n \\\"TFY_NATS_URL and TFY_NATS_SUBJECT_PREFIX must be set together; \\\"\\n f\\\"got TFY_NATS_URL={'set' if _nats_url else 'unset'}, \\\"\\n f\\\"TFY_NATS_SUBJECT_PREFIX={'set' if _nats_subject_prefix else 'unset'}\\\"\\n ) from None\\n\\n_nats_trace_headers: dict[str, str] = {}\\nif _traceparent := os.environ.get(\\\"TFY_TRACEPARENT\\\"):\\n _nats_trace_headers[\\\"traceparent\\\"] = _traceparent\\nif _tracestate := os.environ.get(\\\"TFY_TRACESTATE\\\"):\\n _nats_trace_headers[\\\"tracestate\\\"] = _tracestate\\n\\n\\nclass _ToolsCache(BaseModel):\\n fetched_at: float\\n tools: list[Tool]\\n\\n\\nclass _BridgeReply(BaseModel):\\n \\\"\\\"\\\"NATS bridge reply envelope. `result` is left untyped — operation-specific callers\\n (Tool / CallToolResult) validate the payload; this model only validates the envelope.\\\"\\\"\\\"\\n\\n ok: bool\\n result: Any = None\\n error: str = \\\"unknown error\\\"\\n source: str = \\\"internal\\\"\\n\\n\\nclass _ServerConfig(TypedDict):\\n allowed_tools: list[str]\\n\\n\\n_TOOLS_CACHE_TTL_SECONDS = 600\\n\\n_inflight_list_tools: dict[str, asyncio.Task[list[Tool]]] = {}\\n\\n_raw_servers = os.environ.get(\\\"TFY_MCP_SERVERS\\\")\\n_servers_map: dict[str, _ServerConfig] = json.loads(base64.b64decode(_raw_servers).decode()) if _raw_servers else {}\\n# Approvals are always enabled (Sandbox always injects TFY_ENABLE_AGENT_APPROVALS=true).\\n_enable_agent_approvals = os.environ.get(\\\"TFY_ENABLE_AGENT_APPROVALS\\\", \\\"true\\\").lower() == \\\"true\\\"\\n\\n\\ndef _check_tool_allowed(server: str, tool_name: str) -> None:\\n server_config = _servers_map.get(server)\\n if server_config is None:\\n raise RuntimeError(f\\\"Access denied: MCP server '{server}' is not available for this agent\\\") from None\\n server_tools = server_config.get(\\\"allowed_tools\\\") or []\\n if len(server_tools) > 0 and tool_name not in server_tools:\\n raise RuntimeError(f\\\"Access denied: tool '{tool_name}' is not enabled on server '{server}'\\\") from None\\n\\n\\ndef _cache_path(server: str) -> Path:\\n return Path(__file__).parent / f\\\"{server}.tools.json\\\"\\n\\n\\ndef _read_tools_cache(server: str) -> _ToolsCache | None:\\n p = _cache_path(server)\\n if not p.exists():\\n return None\\n try:\\n cache = _ToolsCache.model_validate_json(p.read_text(encoding=\\\"utf-8\\\"))\\n except Exception:\\n logger.exception(\\\"_ToolsCache.model_validate_json\\\")\\n p.unlink(missing_ok=True)\\n return None\\n if time.time() - cache.fetched_at > _TOOLS_CACHE_TTL_SECONDS:\\n p.unlink(missing_ok=True)\\n return None\\n return cache\\n\\n\\ndef _write_tools_cache(server: str, tools: list[Tool]) -> None:\\n try:\\n cache = _ToolsCache(fetched_at=time.time(), tools=tools)\\n _cache_path(server).write_text(cache.model_dump_json(), encoding=\\\"utf-8\\\")\\n except Exception:\\n logger.exception(\\\"_write_tools_cache\\\")\\n\\n\\ndef _require_nats_subject() -> str:\\n if not _NATS_SUBJECT or not _nats_url:\\n raise RuntimeError(\\n \\\"NATS bridge setup failed; NATS bridge is required for MCP from Code Mode. \\\"\\n \\\"Try again.\\\"\\n ) from None\\n return _NATS_SUBJECT\\n\\n\\nasync def _fetch_tools(server: str) -> list[Tool]:\\n result = await _nats_request(_require_nats_subject(), {\\\"op\\\": \\\"list_tools\\\", \\\"server\\\": server})\\n if not isinstance(result, dict):\\n raise RuntimeError(f\\\"NATS list_tools '{server}' returned unexpected shape: {result!r}\\\") from None\\n return [Tool.model_validate(t) for t in result.get(\\\"tools\\\", [])]\\n\\n\\nasync def _fetch_and_cache_tools(server: str) -> list[Tool]:\\n tools = await _fetch_tools(server)\\n _write_tools_cache(server, tools)\\n return tools\\n\\n\\nasync def _nats_request(subject: str, payload: dict[str, Any]) -> Any:\\n \\\"\\\"\\\"Send one request over a short-lived bridge connection, unwrap { ok, result|error }.\\n\\n Connection is per-call (closed in `finally`) to avoid leaking nats-py's aiohttp session.\\n Connect and request are retried in separate loops so a successful connection isn't torn\\n down and rebuilt on a request-level retry. Retries only failures that prove the request\\n never reached a handler (connect failure, NoRespondersError); timeouts and post-delivery\\n failures are not retried so a non-idempotent tool can't double-execute.\\n \\\"\\\"\\\"\\n import nats\\n from nats.errors import NoRespondersError\\n\\n data = json.dumps(payload).encode()\\n request_timeout = float(os.environ[\\\"TFY_CM_REQUEST_TIMEOUT_SECONDS\\\"])\\n\\n # Phase 1: establish a connection, retrying only connect failures.\\n nc = None\\n last_connect_error = \\\"unknown error\\\"\\n for attempt in range(1, _NATS_REQUEST_MAX_ATTEMPTS + 1):\\n if attempt > 1:\\n await asyncio.sleep(_NATS_RETRY_BACKOFF_MS / 1000)\\n try:\\n nc = await nats.connect(_nats_url)\\n break\\n except Exception as e:\\n last_connect_error = f\\\"connect to NATS at {_nats_url} failed: {e}\\\"\\n if nc is None:\\n raise RuntimeError(\\n f\\\"NATS connect failed for '{subject}' after {_NATS_REQUEST_MAX_ATTEMPTS} attempts: {last_connect_error}\\\"\\n ) from None\\n\\n # Phase 2: request over the established connection, retrying only NoRespondersError.\\n try:\\n last_transport_error = \\\"unknown error\\\"\\n for attempt in range(1, _NATS_REQUEST_MAX_ATTEMPTS + 1):\\n if attempt > 1:\\n await asyncio.sleep(_NATS_RETRY_BACKOFF_MS / 1000)\\n try:\\n msg = await nc.request(\\n subject,\\n data,\\n timeout=request_timeout,\\n headers=_nats_trace_headers or None,\\n )\\n except NoRespondersError as e:\\n # Server confirms no subscriber — never delivered, safe to retry.\\n last_transport_error = f\\\"no NATS responder for '{subject}': {e}\\\"\\n continue\\n except Exception as e:\\n # Timeout / other: the tool may already have run, so we must not retry.\\n raise RuntimeError(f\\\"NATS request '{subject}' failed: {e}\\\") from None\\n try:\\n reply = _BridgeReply.model_validate_json(msg.data.decode())\\n except ValidationError as e:\\n raise RuntimeError(f\\\"NATS reply on '{subject}' is malformed: {e}\\\") from None\\n if not reply.ok:\\n # A well-formed reply means the transport worked; `source` says who's at fault.\\n if reply.source == \\\"caller\\\":\\n raise RuntimeError(f\\\"Invalid MCP request on '{subject}': {reply.error}\\\") from None\\n if reply.source == \\\"transport\\\":\\n raise RuntimeError(f\\\"Code Mode transport error on '{subject}': {reply.error}\\\") from None\\n raise RuntimeError(f\\\"Internal MCP error on '{subject}': {reply.error}\\\") from None\\n return reply.result\\n raise RuntimeError(\\n f\\\"NATS bridge has no responder for '{subject}' after {_NATS_REQUEST_MAX_ATTEMPTS} attempts: {last_transport_error}\\\"\\n ) from None\\n finally:\\n try:\\n await nc.close()\\n except Exception:\\n logger.debug(\\\"Error closing NATS connection\\\", exc_info=True)\\n\\n\\nasync def _get_tools(server: str) -> list[Tool]:\\n cache = _read_tools_cache(server)\\n if cache is not None:\\n return cache.tools\\n task = _inflight_list_tools.get(server)\\n if task is None:\\n task = asyncio.create_task(_fetch_and_cache_tools(server))\\n _inflight_list_tools[server] = task\\n task.add_done_callback(lambda _t: _inflight_list_tools.pop(server, None))\\n return await task\\n\\n\\nasync def _get_tool(server: str, tool_name: str) -> Tool | None:\\n for t in await _get_tools(server):\\n if t.name == tool_name:\\n return t\\n return None\\n\\n\\ndef _is_destructive(tool: Tool) -> bool:\\n annotations = tool.annotations\\n if annotations is None:\\n return False\\n return bool(annotations.destructiveHint) or (not annotations.readOnlyHint and annotations.readOnlyHint is not None)\\n\\nasync def _ensure_non_destructive(server: str, tool_name: str) -> None:\\n tool = await _get_tool(server, tool_name)\\n if tool is None:\\n raise RuntimeError(f\\\"Tool '{tool_name}' not found on MCP server '{server}'\\\") from None\\n if _is_destructive(tool):\\n raise RuntimeError(\\n f\\\"Tool '{tool_name}' on MCP server '{server}' is destructive and cannot be called in Code Mode; \\\"\\n f\\\"call it directly so it can go through the user approval flow\\\"\\n ) from None\\n\\n\\ndef _project_call_tool_result(server: str, tool: str, result: CallToolResult) -> Any:\\n \\\"\\\"\\\"Project an MCP-wire CallToolResult into the user-facing Python value.\\\"\\\"\\\"\\n if result.isError:\\n text_parts = [c.text for c in result.content if isinstance(c, TextContent) and c.text]\\n msg = \\\"; \\\".join(text_parts) if text_parts else \\\"tool returned an error\\\"\\n raise RuntimeError(f\\\"MCP tool error (server={server}, tool={tool}): {msg}\\\") from None\\n\\n if result.structuredContent is not None:\\n return result.structuredContent\\n\\n # Fallback to content blocks (tools without outputSchema / plain text responses)\\n if len(result.content) == 1:\\n first = result.content[0]\\n if isinstance(first, TextContent) and first.text:\\n try:\\n return json.loads(first.text)\\n except Exception:\\n pass\\n if result.content:\\n return result.content\\n return None\\n\\n\\nasync def call_tool(server: str, tool: str, body: dict[str, Any]) -> Any:\\n _check_tool_allowed(server, tool)\\n if _enable_agent_approvals:\\n await _ensure_non_destructive(server, tool)\\n\\n raw = await _nats_request(\\n _require_nats_subject(),\\n {\\\"op\\\": \\\"call_tool\\\", \\\"server\\\": server, \\\"tool\\\": tool, \\\"arguments\\\": body},\\n )\\n try:\\n result = CallToolResult.model_validate(raw)\\n except Exception as e:\\n raise RuntimeError(f\\\"NATS call_tool reply for '{server}/{tool}' is malformed: {e}\\\") from None\\n\\n return _project_call_tool_result(server, tool, result)\\n\\n\\n_USAGE = \\\"mcp_client.py call-tool <server> <tool> <args-json>\\\"\\n\\n\\ndef _build_arg_parser() -> argparse.ArgumentParser:\\n parser = argparse.ArgumentParser(prog=\\\"mcp_client.py\\\", usage=_USAGE)\\n sub = parser.add_subparsers(dest=\\\"cmd\\\", required=True)\\n\\n call_tool_p = sub.add_parser(\\\"call-tool\\\", help=\\\"Invoke an MCP tool\\\")\\n call_tool_p.add_argument(\\\"server\\\")\\n call_tool_p.add_argument(\\\"tool\\\")\\n # Parsing JSON at the argparse boundary surfaces malformed input as a clean\\n # argparse error (with usage) instead of an opaque traceback from inside the handler.\\n call_tool_p.add_argument(\\\"args_json\\\", metavar=\\\"args-json\\\", type=json.loads)\\n\\n return parser\\n\\n\\nasync def _main() -> None:\\n args = _build_arg_parser().parse_args()\\n try:\\n if args.cmd == \\\"call-tool\\\":\\n result = await call_tool(args.server, args.tool, args.args_json)\\n print(json.dumps(result, default=str))\\n except RuntimeError as e:\\n sys.exit(str(e))\\n\\n\\nif __name__ == \\\"__main__\\\":\\n asyncio.run(_main())\\n\",\n gitDownloader: \"#!/usr/bin/env python3\\n\\n# /// script\\n# dependencies = [\\\"pydantic==2.12.5\\\"]\\n# ///\\n\\n\\\"\\\"\\\"Materialize git skill directories into TFY_SKILLS_DIR (default /opt/tfy/skills). Requires `git`.\\n\\nEach git skill (AGENT_GIT_SKILLS) is materialized with a blob-filtered sparse `git` clone that\\nfetches only the requested subdir at a resolved object id, keyed by name. Mount `ref` values\\n(branch, tag, or full object id) are resolved here via `git ls-remote` — the host never spawns\\ngit. A single on-disk state file records the installed object id + subdir per name so a skill\\nwhose tip hasn't moved is skipped instead of re-cloning every run, and skills no longer desired\\nare pruned from disk. A sparse clone is used instead of a full repo tarball because its cost is\\n~constant in the subdir size rather than the whole-repo size, which is dramatically faster for a\\nsmall skill living in a large monorepo.\\n\\nOptional env:\\n - AGENT_GIT_SKILLS (base64-encoded JSON list of {name, url, path, ref}; empty\\n clears git downloads). `ref` is a branch, tag, or full object id.\\n - TFY_SKILLS_DIR: override the skills directory (default /opt/tfy/skills).\\n\\\"\\\"\\\"\\n\\nfrom __future__ import annotations\\n\\nimport base64\\nimport json\\nimport os\\nimport re\\nimport shutil\\nimport subprocess\\nimport sys\\nimport tempfile\\nfrom pathlib import Path\\n\\nfrom pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator\\n\\nDEFAULT_SKILLS_DIR = \\\"/opt/tfy/skills\\\"\\nSTATE_FILE_NAME = \\\".tfy-skill-downloader-state.json\\\"\\n# Per-git-invocation wall-clock cap so a hung/slow fetch can't stall sandbox init indefinitely.\\nGIT_CLONE_TIMEOUT_SECONDS = 120\\n# Cap the installed skill size so a huge repo-root skill can't fill the persistent skills dir. The\\n# sparse clone already bounds a subdir skill to its subdir; this also guards the whole-repo (root\\n# subdir) case where checkout hydrates every file.\\nGIT_SKILL_MAX_BYTES = 200 * 1024 * 1024 # 200MB installed\\n# Resolved object id used for fetch + skip state (sha1 / sha256).\\nOBJECT_ID_RE = re.compile(r\\\"^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$\\\")\\n# Mount ref (branch/tag/SHA) charset — must match host AgentSpec SkillMount.ref validation.\\n# Validated before the value is passed to `git ls-remote` / used as a fetch arg fallback.\\nGIT_REF_RE = re.compile(r\\\"^[A-Za-z0-9._\\\\-/]+$\\\")\\nSKILLS_ROOT = Path(os.environ.get(\\\"TFY_SKILLS_DIR\\\", DEFAULT_SKILLS_DIR))\\nSTATE_PATH = SKILLS_ROOT / STATE_FILE_NAME\\n\\n\\nclass GitSkillError(Exception):\\n \\\"\\\"\\\"Recoverable per-repo/per-skill git failure. Raised (not sys.exit) so a single bad repo or\\n subdirectory doesn't abort the remaining repository groups (best-effort failure isolation).\\\"\\\"\\\"\\n\\n\\ndef skill_dir(name: str) -> Path | None:\\n \\\"\\\"\\\"Resolve a skill's on-disk directory. This is the ONLY place a skill name becomes a path, so\\n the safety check here is what guarantees that no name — freshly requested or read back from the\\n (possibly corrupt) state file — can escape SKILLS_ROOT during delete/copy.\\n\\n Returns None for an unsafe name (empty, absolute, containing a path separator, \\\".\\\" / \\\"..\\\", or the\\n reserved state-file name) so each caller can skip it per its own failure contract instead of\\n touching an out-of-tree path. The state-file name is reserved because a skill whose name equals it\\n would resolve to STATE_PATH and let a skill install clobber the reconciliation state.\\n \\\"\\\"\\\"\\n if not name or name in (\\\".\\\", \\\"..\\\") or \\\"/\\\" in name or \\\"\\\\\\\\\\\" in name or name == STATE_FILE_NAME:\\n return None\\n return SKILLS_ROOT / name\\n\\n\\nclass GitSkill(BaseModel):\\n \\\"\\\"\\\"A git skill to materialize: `name`, `url`, `path`, and mount `ref` (branch, tag, or object id).\\n This script resolves `ref` via `git ls-remote` in the sandbox, then sparse-fetches the resolved\\n object id. The url and ref are format-validated here (defense in depth) so an unexpected value\\n can't be interpreted as a git option or reach a non-public host.\\\"\\\"\\\"\\n\\n model_config = ConfigDict(extra=\\\"ignore\\\")\\n\\n name: str\\n url: str\\n path: str = \\\"\\\"\\n # Branch, tag, or full object id from the agent_spec mount (resolved in-sandbox before fetch).\\n ref: str\\n\\n @field_validator(\\\"url\\\")\\n @classmethod\\n def _validate_url(cls, v: str) -> str:\\n # Only the two public hosts the gateway supports. Anchoring on the \\\"https://<host>/\\\" prefix\\n # blocks non-https schemes (file://, ext::, ssh) and any other host, so this URL can't be\\n # turned into an SSRF/local-file/command vector once it reaches `git`.\\n if not (v.startswith(\\\"https://github.com/\\\") or v.startswith(\\\"https://gitlab.com/\\\")):\\n raise ValueError(f\\\"git skill url must be a github.com/gitlab.com https URL: {v!r}\\\")\\n return v\\n\\n @field_validator(\\\"ref\\\")\\n @classmethod\\n def _validate_ref(cls, v: str) -> str:\\n if not v or \\\"..\\\" in v.split(\\\"/\\\") or not GIT_REF_RE.match(v):\\n raise ValueError(f\\\"git skill ref is invalid: {v!r}\\\")\\n if v.replace(\\\"/\\\", \\\"\\\") == \\\"\\\":\\n raise ValueError(f\\\"git skill ref must not consist only of slashes: {v!r}\\\")\\n return v\\n\\n @field_validator(\\\"path\\\")\\n @classmethod\\n def _validate_path(cls, v: str) -> str:\\n # Relative subpath within the repo. Reject absolute paths and \\\"..\\\" segments so it can't\\n # escape the repo root. Leading/trailing slashes are stripped (repo root == \\\"\\\"). A leading\\n # \\\"-\\\" is safe because subdirs are fed to `git sparse-checkout set` via stdin, never as args.\\n if v.startswith(\\\"/\\\"):\\n raise ValueError(f\\\"git skill path must be relative: {v!r}\\\")\\n stripped = v.strip(\\\"/\\\")\\n if stripped and \\\"..\\\" in stripped.split(\\\"/\\\"):\\n raise ValueError(f\\\"git skill path must not contain '..': {v!r}\\\")\\n return stripped\\n\\n\\nclass DownloadedGitSkill(BaseModel):\\n \\\"\\\"\\\"A git skill recorded on disk: its dir name, the object id it was installed at, and the repo\\n path it was installed from. The ref lets a later run skip re-downloading when the requested\\n pin is unchanged; the path is part of the identity so that changing the path at the same\\n pin still triggers a re-download instead of serving the previously-installed layout.\\\"\\\"\\\"\\n\\n model_config = ConfigDict(extra=\\\"ignore\\\")\\n\\n name: str\\n # Empty when migrated from a pre-pin-tracking state file — treated as \\\"unknown\\\", forcing a\\n # one-time re-download while still keeping the name tracked for pruning.\\n ref: str = \\\"\\\"\\n # Repo path the skill was installed from (repo root == \\\"\\\"). Empty on entries written before\\n # path tracking; a mismatch against the requested path forces a one-time re-download.\\n path: str = \\\"\\\"\\n\\n\\nclass SkillDownloaderState(BaseModel):\\n \\\"\\\"\\\"Persisted next to skills; tracks what was downloaded so undesired skills can be pruned.\\\"\\\"\\\"\\n\\n model_config = ConfigDict(extra=\\\"ignore\\\")\\n\\n downloaded_git_skills: list[DownloadedGitSkill] = Field(\\n default_factory=list,\\n description=\\\"Git skills successfully downloaded (name + installed object id).\\\",\\n )\\n\\n\\ndef _migrate_downloaded_git_skills(entries: list[object]) -> list[dict[str, object]]:\\n \\\"\\\"\\\"Normalize state entries: rename legacy `commit_sha` → `ref` and `subdir` → `path`.\\\"\\\"\\\"\\n migrated: list[dict[str, object]] = []\\n for item in entries:\\n if not isinstance(item, dict):\\n continue\\n entry = dict(item)\\n if \\\"ref\\\" not in entry and isinstance(entry.get(\\\"commit_sha\\\"), str):\\n entry[\\\"ref\\\"] = entry[\\\"commit_sha\\\"]\\n entry.pop(\\\"commit_sha\\\", None)\\n if \\\"path\\\" not in entry and isinstance(entry.get(\\\"subdir\\\"), str):\\n entry[\\\"path\\\"] = entry[\\\"subdir\\\"]\\n entry.pop(\\\"subdir\\\", None)\\n migrated.append(entry)\\n return migrated\\n\\n\\ndef load_state() -> SkillDownloaderState:\\n if not STATE_PATH.is_file():\\n return SkillDownloaderState()\\n try:\\n raw = json.loads(STATE_PATH.read_text(encoding=\\\"utf-8\\\"))\\n except (OSError, ValueError):\\n return SkillDownloaderState()\\n if not isinstance(raw, dict):\\n return SkillDownloaderState()\\n # Migrate a pre-pin-tracking state file: its `downloaded_git_names` (list of names, no pin)\\n # becomes entries with an empty ref. That keeps the names tracked so undesired skills are\\n # still pruned, and forces a one-time re-download (empty != any requested pin). Skipped when\\n # the new field is already present so we never clobber real pins.\\n if \\\"downloaded_git_skills\\\" not in raw and isinstance(raw.get(\\\"downloaded_git_names\\\"), list):\\n raw[\\\"downloaded_git_skills\\\"] = [\\n {\\\"name\\\": n, \\\"ref\\\": \\\"\\\"} for n in raw[\\\"downloaded_git_names\\\"] if isinstance(n, str)\\n ]\\n if isinstance(raw.get(\\\"downloaded_git_skills\\\"), list):\\n raw[\\\"downloaded_git_skills\\\"] = _migrate_downloaded_git_skills(raw[\\\"downloaded_git_skills\\\"])\\n try:\\n return SkillDownloaderState.model_validate(raw)\\n except ValidationError:\\n return SkillDownloaderState()\\n\\n\\ndef save_state(state: SkillDownloaderState) -> None:\\n STATE_PATH.parent.mkdir(parents=True, exist_ok=True)\\n tmp = STATE_PATH.with_name(STATE_PATH.name + \\\".tmp\\\")\\n tmp.write_text(state.model_dump_json(indent=2), encoding=\\\"utf-8\\\")\\n os.replace(tmp, STATE_PATH)\\n\\n\\ndef _rmtree(path: Path, ignore_errors: bool = True) -> None:\\n try:\\n shutil.rmtree(path, ignore_errors=ignore_errors)\\n except OSError:\\n pass\\n\\n\\ndef _delete_skill_dir_by_name(name: str, label: str) -> None:\\n \\\"\\\"\\\"Fail-closed prune of a no-longer-desired skill's directory: abort the whole run if removal\\n fails so we never silently leave a stale skill on disk. skill_dir() returns None for a\\n corrupt/tampered name, so rmtree never runs on an out-of-tree path.\\\"\\\"\\\"\\n dir_ = skill_dir(name)\\n if dir_ is None or not dir_.is_dir():\\n return\\n try:\\n shutil.rmtree(dir_)\\n except OSError as e:\\n sys.exit(f\\\"Could not remove skill directory {dir_} ({label}): {e}\\\")\\n\\n\\ndef git_skills_from_env(raw: str | None) -> list[GitSkill]:\\n \\\"\\\"\\\"Empty or missing env means \\\"no desired git skills\\\" (prune all git downloads).\\\"\\\"\\\"\\n if raw is None or not str(raw).strip():\\n return []\\n try:\\n data = json.loads(base64.b64decode(raw))\\n except (ValueError, json.JSONDecodeError) as e:\\n sys.exit(f\\\"AGENT_GIT_SKILLS is not valid base64-encoded JSON: {e}\\\")\\n if not isinstance(data, list):\\n sys.exit(\\\"AGENT_GIT_SKILLS must be a JSON list.\\\")\\n try:\\n return [GitSkill.model_validate(item) for item in data]\\n except ValidationError as e:\\n sys.exit(f\\\"Invalid AGENT_GIT_SKILLS entry: {e}\\\")\\n\\n\\ndef reconcile_git_skills(git_skills: list[GitSkill], state: SkillDownloaderState) -> list[str]:\\n \\\"\\\"\\\"Delete dirs for git skills no longer desired; prune them from state. Returns removed names.\\\"\\\"\\\"\\n desired_names = {g.name for g in git_skills}\\n removed: list[str] = []\\n for entry in state.downloaded_git_skills:\\n if entry.name in desired_names:\\n continue\\n # Fail-closed prune: a failed delete aborts the run rather than leaving a stale skill on disk.\\n # The name is dropped from tracking either way.\\n _delete_skill_dir_by_name(entry.name, entry.name)\\n removed.append(entry.name)\\n state.downloaded_git_skills = [\\n e for e in state.downloaded_git_skills if e.name in desired_names\\n ]\\n return removed\\n\\n\\ndef _run_git(\\n args: list[str], cwd: Path, input_text: str | None = None\\n) -> subprocess.CompletedProcess[str]:\\n \\\"\\\"\\\"Run a git subcommand non-interactively and fail-closed on error.\\n\\n Args are passed as a list (never a shell string) and git runs with a scrubbed environment:\\n ambient global/system config and credential helpers are ignored (so nothing can rewrite the URL\\n via insteadOf or inject creds) and prompting is disabled (so a private/typo repo fails fast\\n instead of hanging on a username prompt).\\\"\\\"\\\"\\n env = {\\n **os.environ,\\n \\\"GIT_TERMINAL_PROMPT\\\": \\\"0\\\", # never prompt for credentials — fail fast on a non-public repo\\n \\\"GIT_CONFIG_NOSYSTEM\\\": \\\"1\\\", # ignore /etc/gitconfig\\n \\\"GIT_CONFIG_GLOBAL\\\": \\\"/dev/null\\\", # ignore ~/.gitconfig (insteadOf rewrites, cred helpers)\\n \\\"GCM_INTERACTIVE\\\": \\\"never\\\",\\n }\\n try:\\n proc = subprocess.run(\\n [\\\"git\\\", *args],\\n cwd=os.fspath(cwd),\\n env=env,\\n input=input_text,\\n capture_output=True,\\n text=True,\\n timeout=GIT_CLONE_TIMEOUT_SECONDS,\\n )\\n except subprocess.TimeoutExpired:\\n raise GitSkillError(f\\\"git {args[0]} timed out after {GIT_CLONE_TIMEOUT_SECONDS}s\\\")\\n except OSError as e:\\n raise GitSkillError(f\\\"could not run git (is it installed in the sandbox?): {e}\\\")\\n if proc.returncode != 0:\\n detail = (proc.stderr or proc.stdout or \\\"\\\").strip()[-500:]\\n raise GitSkillError(f\\\"git {args[0]} failed (exit {proc.returncode}): {detail}\\\")\\n return proc\\n\\n\\ndef _parse_ls_remote_object_id(stdout: str) -> str | None:\\n \\\"\\\"\\\"Prefer a peeled `ref^{}` line (annotated tags), else the first object id.\\\"\\\"\\\"\\n first: str | None = None\\n for line in stdout.splitlines():\\n trimmed = line.strip()\\n if not trimmed:\\n continue\\n parts = trimmed.split(None, 1)\\n sha = parts[0]\\n name = parts[1] if len(parts) > 1 else \\\"\\\"\\n if name.endswith(\\\"^{}\\\"):\\n return sha\\n if first is None:\\n first = sha\\n return first\\n\\n\\ndef _resolve_object_id(url: str, ref: str, cwd: Path) -> str:\\n \\\"\\\"\\\"Resolve a mount ref (branch/tag/SHA) to an object id inside the sandbox.\\n\\n Full object ids are used as-is (`git ls-remote` does not advertise bare SHAs on\\n GitHub/GitLab). Branches and tags are resolved via `git ls-remote` so a branch tip\\n advances on later sandbox inits.\\n \\\"\\\"\\\"\\n if OBJECT_ID_RE.match(ref):\\n return ref\\n proc = _run_git([\\\"ls-remote\\\", url, ref], cwd=cwd)\\n oid = _parse_ls_remote_object_id(proc.stdout or \\\"\\\")\\n if oid is None:\\n raise GitSkillError(f\\\"git ls-remote returned no commit for {url} ref {ref}\\\")\\n return oid\\n\\n\\ndef _git_fetch_repo(\\n url: str, object_id: str, paths: list[str], parent_dir: Path\\n) -> Path:\\n \\\"\\\"\\\"Object-id-pinned, shallow, blob-filtered sparse checkout of `paths` into a fresh repo under\\n `parent_dir`, returning the repo working directory.\\n\\n When every requested path is the repo root (\\\"\\\") we can't sparse-restrict, so we do a full\\n shallow checkout (all blobs). Otherwise we cone-sparse to just the requested paths and use\\n --filter=blob:none so only those paths' blobs are fetched — the whole point of the approach.\\n Paths are fed to `git sparse-checkout set` via stdin so a path value can never be read as an\\n option/argument. Fetching by object id relies on the server allowing it, which github.com and\\n gitlab.com both do.\\\"\\\"\\\"\\n repo = parent_dir / \\\"repo\\\"\\n _run_git([\\\"init\\\", \\\"-q\\\", os.fspath(repo)], cwd=parent_dir)\\n _run_git([\\\"remote\\\", \\\"add\\\", \\\"origin\\\", url], cwd=repo)\\n\\n cone_dirs = [s for s in dict.fromkeys(paths) if s] # de-duped, root (\\\"\\\") dropped\\n want_full_tree = any(s == \\\"\\\" for s in paths)\\n if not want_full_tree and cone_dirs:\\n _run_git([\\\"sparse-checkout\\\", \\\"init\\\", \\\"--cone\\\"], cwd=repo)\\n _run_git(\\n [\\\"sparse-checkout\\\", \\\"set\\\", \\\"--stdin\\\"],\\n cwd=repo,\\n input_text=\\\"\\\".join(f\\\"{d}\\\\n\\\" for d in cone_dirs),\\n )\\n\\n fetch_args = [\\\"-c\\\", \\\"protocol.file.allow=never\\\", \\\"fetch\\\", \\\"-q\\\", \\\"--depth\\\", \\\"1\\\"]\\n if not want_full_tree:\\n fetch_args.append(\\\"--filter=blob:none\\\")\\n fetch_args += [\\\"origin\\\", object_id]\\n _run_git(fetch_args, cwd=repo)\\n\\n _run_git(\\n [\\\"-c\\\", \\\"advice.detachedHead=false\\\", \\\"checkout\\\", \\\"-q\\\", \\\"--detach\\\", \\\"FETCH_HEAD\\\"], cwd=repo\\n )\\n return repo\\n\\n\\ndef _installed_size_bytes(path: Path) -> int:\\n \\\"\\\"\\\"Total size of regular files under `path`, excluding the repo's .git dir and without following\\n symlinks (they're copied as links, not their targets), used to enforce GIT_SKILL_MAX_BYTES.\\\"\\\"\\\"\\n total = 0\\n for root, dirs, files in os.walk(path, followlinks=False):\\n if \\\".git\\\" in dirs:\\n dirs.remove(\\\".git\\\")\\n for name in files:\\n try:\\n total += (Path(root) / name).lstat().st_size\\n except OSError:\\n continue\\n return total\\n\\n\\ndef _install_git_skill(repo_root: Path, skill: GitSkill) -> None:\\n dest = skill_dir(skill.name)\\n if dest is None:\\n raise GitSkillError(f\\\"Git skill: unsafe directory name {skill.name!r}\\\")\\n repo_root_resolved = repo_root.resolve()\\n src = (repo_root / skill.path).resolve() if skill.path else repo_root_resolved\\n # Defense in depth: even though path is validated, ensure the resolved source stays within the\\n # cloned repo (guards against a symlinked directory component pointing outside the checkout).\\n if src != repo_root_resolved and not src.is_relative_to(repo_root_resolved):\\n raise GitSkillError(\\n f\\\"Git skill {skill.name}: path '{skill.path}' escapes the repository\\\"\\n )\\n if not src.is_dir():\\n raise GitSkillError(\\n f\\\"Git skill {skill.name}: path '{skill.path}' not found in repository\\\"\\n )\\n if _installed_size_bytes(src) > GIT_SKILL_MAX_BYTES:\\n raise GitSkillError(\\n f\\\"Git skill {skill.name}: content exceeds {GIT_SKILL_MAX_BYTES} bytes\\\"\\n )\\n # Stage the fresh copy into a temp sibling dir, then swap it in with an atomic rename. `dest` is\\n # only touched once the copy has fully succeeded, so a failed/partial copytree can never leave\\n # the skill missing (which would contradict the path the gateway advertises in the prompt) — the\\n # previously installed copy stays intact on failure. `.git` is excluded (only present for a repo-\\n # root skill) and symlinks are copied as links, never followed, so a link inside the repo can't\\n # pull external file contents into the skill dir at copy time.\\n staging = dest.with_name(f\\\".{dest.name}.new-{os.getpid()}\\\")\\n _rmtree(staging) # clear any leftover staging dir from an earlier aborted run\\n try:\\n shutil.copytree(src, staging, symlinks=True, ignore=shutil.ignore_patterns(\\\".git\\\"))\\n except (OSError, shutil.Error) as e:\\n # shutil.Error (a multi-file copy error, NOT an OSError subclass) must be caught here too so a\\n # bad copy is reported as a per-skill GitSkillError (WARNING + counted failure) instead of an\\n # uncaught traceback that aborts the whole run and skips save_state for the group.\\n _rmtree(staging)\\n raise GitSkillError(f\\\"Failed to stage git skill {skill.name}: {e}\\\")\\n try:\\n if dest.exists():\\n shutil.rmtree(dest)\\n # Same filesystem (both under SKILLS_ROOT), so this is an atomic rename; the window where the\\n # skill dir is absent shrinks from a full copy to a single near-instant rename.\\n os.replace(staging, dest)\\n except OSError as e:\\n _rmtree(staging)\\n raise GitSkillError(f\\\"Failed to install git skill {skill.name}: {e}\\\")\\n\\n\\ndef _mark_git_downloaded(\\n state: SkillDownloaderState, skill: GitSkill, object_id: str\\n) -> None:\\n \\\"\\\"\\\"Upsert a git skill's installed object id + path so the next run can skip when both match.\\\"\\\"\\\"\\n for entry in state.downloaded_git_skills:\\n if entry.name == skill.name:\\n entry.ref = object_id\\n entry.path = skill.path\\n return\\n state.downloaded_git_skills.append(\\n DownloadedGitSkill(name=skill.name, ref=object_id, path=skill.path)\\n )\\n\\n\\ndef _git_skill_already_installed(\\n state: SkillDownloaderState, skill: GitSkill, object_id: str\\n) -> bool:\\n \\\"\\\"\\\"True only when the skill is recorded at the resolved object id AND path AND its directory is\\n still present. Re-download otherwise (branch tip moved, path changed, first install, or the\\n dir was removed out-of-band) so the prompt never points at a missing/stale skill dir.\\\"\\\"\\\"\\n dir_ = skill_dir(skill.name)\\n if dir_ is None or not dir_.is_dir():\\n return False\\n return any(\\n e.name == skill.name and e.ref == object_id and e.path == skill.path\\n for e in state.downloaded_git_skills\\n )\\n\\n\\ndef download_git_skills(\\n git_skills: list[GitSkill], state: SkillDownloaderState\\n) -> int:\\n \\\"\\\"\\\"Ensure desired git skills are installed at their resolved object id, skipping any already\\n present at that pin so an unchanged tip no longer re-clones every run.\\n\\n Ref resolution (`git ls-remote`) runs here in the sandbox — the host never spawns git.\\n Failures are isolated per repo group and per skill so one bad repo/subdir doesn't prevent the\\n rest from being attempted; each failure is logged to stderr. Returns the number of skills that are\\n satisfied this run — freshly installed OR already present at the resolved pin (never raises).\\n The caller (run_git_download) compares this to the number requested and exits non-zero if any are\\n missing, so the overall behaviour is fail-closed.\\\"\\\"\\\"\\n SKILLS_ROOT.mkdir(parents=True, exist_ok=True)\\n\\n # Resolve mount refs → object ids, then skip skills already installed at that pin.\\n pending: list[tuple[GitSkill, str]] = []\\n satisfied = 0\\n for skill in git_skills:\\n try:\\n object_id = _resolve_object_id(skill.url, skill.ref, SKILLS_ROOT)\\n except GitSkillError as e:\\n print(f\\\"WARNING: {e} (skill: {skill.name})\\\", file=sys.stderr)\\n continue\\n if _git_skill_already_installed(state, skill, object_id):\\n satisfied += 1\\n else:\\n pending.append((skill, object_id))\\n\\n # Group pending by (repo, object id) so a repo shared by several skills is cloned once and its\\n # needed paths fetched together in a single sparse checkout.\\n skills_by_repo: dict[tuple[str, str], list[tuple[GitSkill, str]]] = {}\\n for skill, object_id in pending:\\n skills_by_repo.setdefault((skill.url, object_id), []).append((skill, object_id))\\n\\n for (url, object_id), group in skills_by_repo.items():\\n try:\\n with tempfile.TemporaryDirectory(\\n prefix=\\\".git-skill-dl-\\\",\\n dir=os.fspath(SKILLS_ROOT),\\n ignore_cleanup_errors=True,\\n ) as tmp_str:\\n repo_root = _git_fetch_repo(\\n url,\\n object_id,\\n [s.path for s, _ in group],\\n Path(tmp_str),\\n )\\n # Install each skill independently so a bad path only skips that one skill.\\n for skill, oid in group:\\n try:\\n _install_git_skill(repo_root, skill)\\n _mark_git_downloaded(state, skill, oid)\\n satisfied += 1\\n except GitSkillError as e:\\n print(f\\\"WARNING: {e}\\\", file=sys.stderr)\\n except GitSkillError as e:\\n # Collect (don't abort-on-first) so EVERY failing skill is reported before the run exits;\\n # run_git_download turns a non-zero failed count into a non-zero exit (fail-closed), which\\n # fails the whole agent request. We still don't purge a previously installed copy here, so\\n # a reused sandbox keeps the old files on disk even though this run is about to fail.\\n names = \\\", \\\".join(s.name for s, _ in group)\\n print(f\\\"WARNING: {e} (skills: {names})\\\", file=sys.stderr)\\n save_state(state)\\n return satisfied\\n\\n\\ndef run_git_download() -> None:\\n git_skills = git_skills_from_env(os.environ.get(\\\"AGENT_GIT_SKILLS\\\"))\\n state = load_state()\\n removed = reconcile_git_skills(git_skills, state)\\n save_state(state) # persist pruning even when there is nothing to download\\n\\n if not git_skills:\\n if removed:\\n print(f\\\"Removed {len(removed)} git skill(s) from disk. (AGENT_GIT_SKILLS empty.)\\\")\\n return\\n\\n satisfied = download_git_skills(git_skills, state)\\n failed = len(git_skills) - satisfied\\n suffix = f\\\" Removed {len(removed)} stale git skill(s).\\\" if removed else \\\"\\\"\\n if failed:\\n # Fail-closed: a requested skill that couldn't be installed exits non-zero so the gateway\\n # (ensureExecSuccess) fails the whole agent request, matching the fail-closed SKILL.md fetch.\\n # Per-skill WARNINGs above list exactly what failed.\\n sys.exit(\\n f\\\"Failed to install {failed}/{len(git_skills)} git skill(s); see warnings above.{suffix}\\\"\\n )\\n print(f\\\"Ensured {satisfied} git skill(s) (downloaded or already up to date).{suffix}\\\")\\n\\n\\ndef main() -> None:\\n run_git_download()\\n\\n\\nif __name__ == \\\"__main__\\\":\\n main()\\n\",\n} as const;\n"],"mappings":";AACO,IAAM,iBAAiB;AAAA,EAC5B,WAAW;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EACX,eAAe;AACjB;","names":[]}
1
+ {"version":3,"sources":["../../../src/core/sandbox/sandboxScripts.gen.ts"],"sourcesContent":["// AUTO-GENERATED by scripts/generate-sandbox-scripts.mjs — do not edit.\nexport const sandboxScripts = {\n mcpClient: \"#!/usr/bin/env python3\\n\\n# /// script\\n# dependencies = [\\\"mcp==1.29.0\\\", \\\"pydantic==2.12.5\\\", \\\"nats-py==2.15.0\\\"]\\n# ///\\n\\nimport os\\nimport sys\\nimport json\\nimport time\\nimport base64\\nimport asyncio\\nimport logging\\nimport argparse\\nfrom pathlib import Path\\nfrom typing import Any, TypedDict\\n\\nfrom pydantic import BaseModel, ValidationError\\n\\nfrom mcp.types import CallToolResult, TextContent, Tool\\n\\nlogger = logging.getLogger(__name__)\\n\\n_NATS_REQUEST_MAX_ATTEMPTS = 3\\n_NATS_RETRY_BACKOFF_MS = 250\\n\\n_nats_url = os.environ.get(\\\"TFY_NATS_URL\\\")\\n_nats_subject_prefix = os.environ.get(\\\"TFY_NATS_SUBJECT_PREFIX\\\")\\n_NATS_SUBJECT = f\\\"{_nats_subject_prefix}.mcp\\\" if _nats_url and _nats_subject_prefix else None\\nif bool(_nats_url) != bool(_nats_subject_prefix):\\n raise RuntimeError(\\n \\\"TFY_NATS_URL and TFY_NATS_SUBJECT_PREFIX must be set together; \\\"\\n f\\\"got TFY_NATS_URL={'set' if _nats_url else 'unset'}, \\\"\\n f\\\"TFY_NATS_SUBJECT_PREFIX={'set' if _nats_subject_prefix else 'unset'}\\\"\\n ) from None\\n\\n_nats_trace_headers: dict[str, str] = {}\\nif _traceparent := os.environ.get(\\\"TFY_TRACEPARENT\\\"):\\n _nats_trace_headers[\\\"traceparent\\\"] = _traceparent\\nif _tracestate := os.environ.get(\\\"TFY_TRACESTATE\\\"):\\n _nats_trace_headers[\\\"tracestate\\\"] = _tracestate\\n\\n\\nclass _ToolsCache(BaseModel):\\n fetched_at: float\\n tools: list[Tool]\\n\\n\\nclass _BridgeReply(BaseModel):\\n \\\"\\\"\\\"NATS bridge reply envelope. `result` is left untyped — operation-specific callers\\n (Tool / CallToolResult) validate the payload; this model only validates the envelope.\\\"\\\"\\\"\\n\\n ok: bool\\n result: Any = None\\n error: str = \\\"unknown error\\\"\\n source: str = \\\"internal\\\"\\n\\n\\nclass _ServerConfig(TypedDict):\\n allowed_tools: list[str]\\n\\n\\n_TOOLS_CACHE_TTL_SECONDS = 600\\n\\n_inflight_list_tools: dict[str, asyncio.Task[list[Tool]]] = {}\\n\\n_raw_servers = os.environ.get(\\\"TFY_MCP_SERVERS\\\")\\n_servers_map: dict[str, _ServerConfig] = json.loads(base64.b64decode(_raw_servers).decode()) if _raw_servers else {}\\n# Approvals are always enabled (Sandbox always injects TFY_ENABLE_AGENT_APPROVALS=true).\\n_enable_agent_approvals = os.environ.get(\\\"TFY_ENABLE_AGENT_APPROVALS\\\", \\\"true\\\").lower() == \\\"true\\\"\\n\\n\\ndef _check_tool_allowed(server: str, tool_name: str) -> None:\\n server_config = _servers_map.get(server)\\n if server_config is None:\\n raise RuntimeError(f\\\"Access denied: MCP server '{server}' is not available for this agent\\\") from None\\n server_tools = server_config.get(\\\"allowed_tools\\\") or []\\n if len(server_tools) > 0 and tool_name not in server_tools:\\n raise RuntimeError(f\\\"Access denied: tool '{tool_name}' is not enabled on server '{server}'\\\") from None\\n\\n\\ndef _cache_path(server: str) -> Path:\\n return Path(__file__).parent / f\\\"{server}.tools.json\\\"\\n\\n\\ndef _read_tools_cache(server: str) -> _ToolsCache | None:\\n p = _cache_path(server)\\n if not p.exists():\\n return None\\n try:\\n cache = _ToolsCache.model_validate_json(p.read_text(encoding=\\\"utf-8\\\"))\\n except Exception:\\n logger.exception(\\\"_ToolsCache.model_validate_json\\\")\\n p.unlink(missing_ok=True)\\n return None\\n if time.time() - cache.fetched_at > _TOOLS_CACHE_TTL_SECONDS:\\n p.unlink(missing_ok=True)\\n return None\\n return cache\\n\\n\\ndef _write_tools_cache(server: str, tools: list[Tool]) -> None:\\n try:\\n cache = _ToolsCache(fetched_at=time.time(), tools=tools)\\n _cache_path(server).write_text(cache.model_dump_json(), encoding=\\\"utf-8\\\")\\n except Exception:\\n logger.exception(\\\"_write_tools_cache\\\")\\n\\n\\ndef _require_nats_subject() -> str:\\n if not _NATS_SUBJECT or not _nats_url:\\n raise RuntimeError(\\n \\\"NATS bridge setup failed; NATS bridge is required for MCP from Code Mode. \\\"\\n \\\"Try again.\\\"\\n ) from None\\n return _NATS_SUBJECT\\n\\n\\nasync def _fetch_tools(server: str) -> list[Tool]:\\n result = await _nats_request(_require_nats_subject(), {\\\"op\\\": \\\"list_tools\\\", \\\"server\\\": server})\\n if not isinstance(result, dict):\\n raise RuntimeError(f\\\"NATS list_tools '{server}' returned unexpected shape: {result!r}\\\") from None\\n return [Tool.model_validate(t) for t in result.get(\\\"tools\\\", [])]\\n\\n\\nasync def _fetch_and_cache_tools(server: str) -> list[Tool]:\\n tools = await _fetch_tools(server)\\n _write_tools_cache(server, tools)\\n return tools\\n\\n\\nasync def _nats_request(subject: str, payload: dict[str, Any]) -> Any:\\n \\\"\\\"\\\"Send one request over a short-lived bridge connection, unwrap { ok, result|error }.\\n\\n Connection is per-call (closed in `finally`) to avoid leaking nats-py's aiohttp session.\\n Connect and request are retried in separate loops so a successful connection isn't torn\\n down and rebuilt on a request-level retry. Retries only failures that prove the request\\n never reached a handler (connect failure, NoRespondersError); timeouts and post-delivery\\n failures are not retried so a non-idempotent tool can't double-execute.\\n \\\"\\\"\\\"\\n import nats\\n from nats.errors import NoRespondersError\\n\\n data = json.dumps(payload).encode()\\n request_timeout = float(os.environ[\\\"TFY_CM_REQUEST_TIMEOUT_SECONDS\\\"])\\n\\n # Phase 1: establish a connection, retrying only connect failures.\\n nc = None\\n last_connect_error = \\\"unknown error\\\"\\n for attempt in range(1, _NATS_REQUEST_MAX_ATTEMPTS + 1):\\n if attempt > 1:\\n await asyncio.sleep(_NATS_RETRY_BACKOFF_MS / 1000)\\n try:\\n nc = await nats.connect(_nats_url)\\n break\\n except Exception as e:\\n last_connect_error = f\\\"connect to NATS at {_nats_url} failed: {e}\\\"\\n if nc is None:\\n raise RuntimeError(\\n f\\\"NATS connect failed for '{subject}' after {_NATS_REQUEST_MAX_ATTEMPTS} attempts: {last_connect_error}\\\"\\n ) from None\\n\\n # Phase 2: request over the established connection, retrying only NoRespondersError.\\n try:\\n last_transport_error = \\\"unknown error\\\"\\n for attempt in range(1, _NATS_REQUEST_MAX_ATTEMPTS + 1):\\n if attempt > 1:\\n await asyncio.sleep(_NATS_RETRY_BACKOFF_MS / 1000)\\n try:\\n msg = await nc.request(\\n subject,\\n data,\\n timeout=request_timeout,\\n headers=_nats_trace_headers or None,\\n )\\n except NoRespondersError as e:\\n # Server confirms no subscriber — never delivered, safe to retry.\\n last_transport_error = f\\\"no NATS responder for '{subject}': {e}\\\"\\n continue\\n except Exception as e:\\n # Timeout / other: the tool may already have run, so we must not retry.\\n raise RuntimeError(f\\\"NATS request '{subject}' failed: {e}\\\") from None\\n try:\\n reply = _BridgeReply.model_validate_json(msg.data.decode())\\n except ValidationError as e:\\n raise RuntimeError(f\\\"NATS reply on '{subject}' is malformed: {e}\\\") from None\\n if not reply.ok:\\n # A well-formed reply means the transport worked; `source` says who's at fault.\\n if reply.source == \\\"caller\\\":\\n raise RuntimeError(f\\\"Invalid MCP request on '{subject}': {reply.error}\\\") from None\\n if reply.source == \\\"transport\\\":\\n raise RuntimeError(f\\\"Code Mode transport error on '{subject}': {reply.error}\\\") from None\\n raise RuntimeError(f\\\"Internal MCP error on '{subject}': {reply.error}\\\") from None\\n return reply.result\\n raise RuntimeError(\\n f\\\"NATS bridge has no responder for '{subject}' after {_NATS_REQUEST_MAX_ATTEMPTS} attempts: {last_transport_error}\\\"\\n ) from None\\n finally:\\n try:\\n await nc.close()\\n except Exception:\\n logger.debug(\\\"Error closing NATS connection\\\", exc_info=True)\\n\\n\\nasync def _get_tools(server: str) -> list[Tool]:\\n cache = _read_tools_cache(server)\\n if cache is not None:\\n return cache.tools\\n task = _inflight_list_tools.get(server)\\n if task is None:\\n task = asyncio.create_task(_fetch_and_cache_tools(server))\\n _inflight_list_tools[server] = task\\n task.add_done_callback(lambda _t: _inflight_list_tools.pop(server, None))\\n return await task\\n\\n\\nasync def _get_tool(server: str, tool_name: str) -> Tool | None:\\n for t in await _get_tools(server):\\n if t.name == tool_name:\\n return t\\n return None\\n\\n\\ndef _is_destructive(tool: Tool) -> bool:\\n annotations = tool.annotations\\n if annotations is None:\\n return False\\n return bool(annotations.destructiveHint) or (not annotations.readOnlyHint and annotations.readOnlyHint is not None)\\n\\nasync def _ensure_non_destructive(server: str, tool_name: str) -> None:\\n tool = await _get_tool(server, tool_name)\\n if tool is None:\\n raise RuntimeError(f\\\"Tool '{tool_name}' not found on MCP server '{server}'\\\") from None\\n if _is_destructive(tool):\\n raise RuntimeError(\\n f\\\"Tool '{tool_name}' on MCP server '{server}' is destructive and cannot be called in Code Mode; \\\"\\n f\\\"call it directly so it can go through the user approval flow\\\"\\n ) from None\\n\\n\\ndef _project_call_tool_result(server: str, tool: str, result: CallToolResult) -> Any:\\n \\\"\\\"\\\"Project an MCP-wire CallToolResult into the user-facing Python value.\\\"\\\"\\\"\\n if result.isError:\\n text_parts = [c.text for c in result.content if isinstance(c, TextContent) and c.text]\\n msg = \\\"; \\\".join(text_parts) if text_parts else \\\"tool returned an error\\\"\\n raise RuntimeError(f\\\"MCP tool error (server={server}, tool={tool}): {msg}\\\") from None\\n\\n if result.structuredContent is not None:\\n return result.structuredContent\\n\\n # Fallback to content blocks (tools without outputSchema / plain text responses)\\n if len(result.content) == 1:\\n first = result.content[0]\\n if isinstance(first, TextContent) and first.text:\\n try:\\n return json.loads(first.text)\\n except Exception:\\n pass\\n if result.content:\\n return result.content\\n return None\\n\\n\\nasync def call_tool(server: str, tool: str, body: dict[str, Any]) -> Any:\\n _check_tool_allowed(server, tool)\\n if _enable_agent_approvals:\\n await _ensure_non_destructive(server, tool)\\n\\n raw = await _nats_request(\\n _require_nats_subject(),\\n {\\\"op\\\": \\\"call_tool\\\", \\\"server\\\": server, \\\"tool\\\": tool, \\\"arguments\\\": body},\\n )\\n try:\\n result = CallToolResult.model_validate(raw)\\n except Exception as e:\\n raise RuntimeError(f\\\"NATS call_tool reply for '{server}/{tool}' is malformed: {e}\\\") from None\\n\\n return _project_call_tool_result(server, tool, result)\\n\\n\\n_USAGE = \\\"mcp_client.py call-tool <server> <tool> <args-json>\\\"\\n\\n\\ndef _build_arg_parser() -> argparse.ArgumentParser:\\n parser = argparse.ArgumentParser(prog=\\\"mcp_client.py\\\", usage=_USAGE)\\n sub = parser.add_subparsers(dest=\\\"cmd\\\", required=True)\\n\\n call_tool_p = sub.add_parser(\\\"call-tool\\\", help=\\\"Invoke an MCP tool\\\")\\n call_tool_p.add_argument(\\\"server\\\")\\n call_tool_p.add_argument(\\\"tool\\\")\\n # Parsing JSON at the argparse boundary surfaces malformed input as a clean\\n # argparse error (with usage) instead of an opaque traceback from inside the handler.\\n call_tool_p.add_argument(\\\"args_json\\\", metavar=\\\"args-json\\\", type=json.loads)\\n\\n return parser\\n\\n\\nasync def _main() -> None:\\n args = _build_arg_parser().parse_args()\\n try:\\n if args.cmd == \\\"call-tool\\\":\\n result = await call_tool(args.server, args.tool, args.args_json)\\n print(json.dumps(result, default=str))\\n except RuntimeError as e:\\n sys.exit(str(e))\\n\\n\\nif __name__ == \\\"__main__\\\":\\n asyncio.run(_main())\\n\",\n gitDownloader: \"#!/usr/bin/env python3\\n\\n# /// script\\n# dependencies = [\\\"pydantic==2.12.5\\\"]\\n# ///\\n\\n\\\"\\\"\\\"Materialize git skill directories into TFY_SKILLS_DIR (default /opt/tfy/skills). Requires `git`.\\n\\nEach git skill (AGENT_GIT_SKILLS) is materialized with a blob-filtered sparse `git` clone that\\nfetches only the requested subdir at a resolved object id, keyed by name. Mount `ref` values\\n(branch, tag, or full object id) are resolved here via `git ls-remote` — the host never spawns\\ngit. A single on-disk state file records the installed object id + subdir per name so a skill\\nwhose tip hasn't moved is skipped instead of re-cloning every run, and skills no longer desired\\nare pruned from disk. A sparse clone is used instead of a full repo tarball because its cost is\\n~constant in the subdir size rather than the whole-repo size, which is dramatically faster for a\\nsmall skill living in a large monorepo.\\n\\nOptional env:\\n - AGENT_GIT_SKILLS (base64-encoded JSON list of {name, url, path, ref}; empty\\n clears git downloads). `ref` is a branch, tag, or full object id.\\n - TFY_SKILLS_DIR: override the skills directory (default /opt/tfy/skills).\\n\\\"\\\"\\\"\\n\\nfrom __future__ import annotations\\n\\nimport base64\\nimport json\\nimport os\\nimport re\\nimport shutil\\nimport subprocess\\nimport sys\\nimport tempfile\\nfrom pathlib import Path\\n\\nfrom pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator\\n\\nDEFAULT_SKILLS_DIR = \\\"/opt/tfy/skills\\\"\\nSTATE_FILE_NAME = \\\".tfy-skill-downloader-state.json\\\"\\n# Per-git-invocation wall-clock cap so a hung/slow fetch can't stall sandbox init indefinitely.\\nGIT_CLONE_TIMEOUT_SECONDS = 120\\n# Cap the installed skill size so a huge repo-root skill can't fill the persistent skills dir. The\\n# sparse clone already bounds a subdir skill to its subdir; this also guards the whole-repo (root\\n# subdir) case where checkout hydrates every file.\\nGIT_SKILL_MAX_BYTES = 200 * 1024 * 1024 # 200MB installed\\n# Resolved object id used for fetch + skip state (sha1 / sha256).\\nOBJECT_ID_RE = re.compile(r\\\"^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$\\\")\\n# Mount ref (branch/tag/SHA) charset — must match host AgentSpec SkillMount.ref validation.\\n# Validated before the value is passed to `git ls-remote` / used as a fetch arg fallback.\\nGIT_REF_RE = re.compile(r\\\"^[A-Za-z0-9._\\\\-/]+$\\\")\\nSKILLS_ROOT = Path(os.environ.get(\\\"TFY_SKILLS_DIR\\\", DEFAULT_SKILLS_DIR))\\nSTATE_PATH = SKILLS_ROOT / STATE_FILE_NAME\\n\\n\\nclass GitSkillError(Exception):\\n \\\"\\\"\\\"Recoverable per-repo/per-skill git failure. Raised (not sys.exit) so a single bad repo or\\n subdirectory doesn't abort the remaining repository groups (best-effort failure isolation).\\\"\\\"\\\"\\n\\n\\ndef skill_dir(name: str) -> Path | None:\\n \\\"\\\"\\\"Resolve a skill's on-disk directory. This is the ONLY place a skill name becomes a path, so\\n the safety check here is what guarantees that no name — freshly requested or read back from the\\n (possibly corrupt) state file — can escape SKILLS_ROOT during delete/copy.\\n\\n Returns None for an unsafe name (empty, absolute, containing a path separator, \\\".\\\" / \\\"..\\\", or the\\n reserved state-file name) so each caller can skip it per its own failure contract instead of\\n touching an out-of-tree path. The state-file name is reserved because a skill whose name equals it\\n would resolve to STATE_PATH and let a skill install clobber the reconciliation state.\\n \\\"\\\"\\\"\\n if not name or name in (\\\".\\\", \\\"..\\\") or \\\"/\\\" in name or \\\"\\\\\\\\\\\" in name or name == STATE_FILE_NAME:\\n return None\\n return SKILLS_ROOT / name\\n\\n\\nclass GitSkill(BaseModel):\\n \\\"\\\"\\\"A git skill to materialize: `name`, `url`, `path`, and mount `ref` (branch, tag, or object id).\\n This script resolves `ref` via `git ls-remote` in the sandbox, then sparse-fetches the resolved\\n object id. The url and ref are format-validated here (defense in depth) so an unexpected value\\n can't be interpreted as a git option or reach a non-public host.\\\"\\\"\\\"\\n\\n model_config = ConfigDict(extra=\\\"ignore\\\")\\n\\n name: str\\n url: str\\n path: str = \\\"\\\"\\n # Branch, tag, or full object id from the agent_spec mount (resolved in-sandbox before fetch).\\n ref: str\\n\\n @field_validator(\\\"url\\\")\\n @classmethod\\n def _validate_url(cls, v: str) -> str:\\n # Only the two public hosts the gateway supports. Anchoring on the \\\"https://<host>/\\\" prefix\\n # blocks non-https schemes (file://, ext::, ssh) and any other host, so this URL can't be\\n # turned into an SSRF/local-file/command vector once it reaches `git`.\\n if not (v.startswith(\\\"https://github.com/\\\") or v.startswith(\\\"https://gitlab.com/\\\")):\\n raise ValueError(f\\\"git skill url must be a github.com/gitlab.com https URL: {v!r}\\\")\\n return v\\n\\n @field_validator(\\\"ref\\\")\\n @classmethod\\n def _validate_ref(cls, v: str) -> str:\\n if not v or \\\"..\\\" in v.split(\\\"/\\\") or not GIT_REF_RE.match(v):\\n raise ValueError(f\\\"git skill ref is invalid: {v!r}\\\")\\n if v.replace(\\\"/\\\", \\\"\\\") == \\\"\\\":\\n raise ValueError(f\\\"git skill ref must not consist only of slashes: {v!r}\\\")\\n return v\\n\\n @field_validator(\\\"path\\\")\\n @classmethod\\n def _validate_path(cls, v: str) -> str:\\n # Relative subpath within the repo. Reject absolute paths and \\\"..\\\" segments so it can't\\n # escape the repo root. Leading/trailing slashes are stripped (repo root == \\\"\\\"). A leading\\n # \\\"-\\\" is safe because subdirs are fed to `git sparse-checkout set` via stdin, never as args.\\n if v.startswith(\\\"/\\\"):\\n raise ValueError(f\\\"git skill path must be relative: {v!r}\\\")\\n stripped = v.strip(\\\"/\\\")\\n if stripped and \\\"..\\\" in stripped.split(\\\"/\\\"):\\n raise ValueError(f\\\"git skill path must not contain '..': {v!r}\\\")\\n return stripped\\n\\n\\nclass DownloadedGitSkill(BaseModel):\\n \\\"\\\"\\\"A git skill recorded on disk: its dir name, the object id it was installed at, and the repo\\n path it was installed from. The ref lets a later run skip re-downloading when the requested\\n pin is unchanged; the path is part of the identity so that changing the path at the same\\n pin still triggers a re-download instead of serving the previously-installed layout.\\\"\\\"\\\"\\n\\n model_config = ConfigDict(extra=\\\"ignore\\\")\\n\\n name: str\\n # Empty when migrated from a pre-pin-tracking state file — treated as \\\"unknown\\\", forcing a\\n # one-time re-download while still keeping the name tracked for pruning.\\n ref: str = \\\"\\\"\\n # Repo path the skill was installed from (repo root == \\\"\\\"). Empty on entries written before\\n # path tracking; a mismatch against the requested path forces a one-time re-download.\\n path: str = \\\"\\\"\\n\\n\\nclass SkillDownloaderState(BaseModel):\\n \\\"\\\"\\\"Persisted next to skills; tracks what was downloaded so undesired skills can be pruned.\\\"\\\"\\\"\\n\\n model_config = ConfigDict(extra=\\\"ignore\\\")\\n\\n downloaded_git_skills: list[DownloadedGitSkill] = Field(\\n default_factory=list,\\n description=\\\"Git skills successfully downloaded (name + installed object id).\\\",\\n )\\n\\n\\ndef _migrate_downloaded_git_skills(entries: list[object]) -> list[dict[str, object]]:\\n \\\"\\\"\\\"Normalize state entries: rename legacy `commit_sha` → `ref` and `subdir` → `path`.\\\"\\\"\\\"\\n migrated: list[dict[str, object]] = []\\n for item in entries:\\n if not isinstance(item, dict):\\n continue\\n entry = dict(item)\\n if \\\"ref\\\" not in entry and isinstance(entry.get(\\\"commit_sha\\\"), str):\\n entry[\\\"ref\\\"] = entry[\\\"commit_sha\\\"]\\n entry.pop(\\\"commit_sha\\\", None)\\n if \\\"path\\\" not in entry and isinstance(entry.get(\\\"subdir\\\"), str):\\n entry[\\\"path\\\"] = entry[\\\"subdir\\\"]\\n entry.pop(\\\"subdir\\\", None)\\n migrated.append(entry)\\n return migrated\\n\\n\\ndef load_state() -> SkillDownloaderState:\\n if not STATE_PATH.is_file():\\n return SkillDownloaderState()\\n try:\\n raw = json.loads(STATE_PATH.read_text(encoding=\\\"utf-8\\\"))\\n except (OSError, ValueError):\\n return SkillDownloaderState()\\n if not isinstance(raw, dict):\\n return SkillDownloaderState()\\n # Migrate a pre-pin-tracking state file: its `downloaded_git_names` (list of names, no pin)\\n # becomes entries with an empty ref. That keeps the names tracked so undesired skills are\\n # still pruned, and forces a one-time re-download (empty != any requested pin). Skipped when\\n # the new field is already present so we never clobber real pins.\\n if \\\"downloaded_git_skills\\\" not in raw and isinstance(raw.get(\\\"downloaded_git_names\\\"), list):\\n raw[\\\"downloaded_git_skills\\\"] = [\\n {\\\"name\\\": n, \\\"ref\\\": \\\"\\\"} for n in raw[\\\"downloaded_git_names\\\"] if isinstance(n, str)\\n ]\\n if isinstance(raw.get(\\\"downloaded_git_skills\\\"), list):\\n raw[\\\"downloaded_git_skills\\\"] = _migrate_downloaded_git_skills(raw[\\\"downloaded_git_skills\\\"])\\n try:\\n return SkillDownloaderState.model_validate(raw)\\n except ValidationError:\\n return SkillDownloaderState()\\n\\n\\ndef save_state(state: SkillDownloaderState) -> None:\\n STATE_PATH.parent.mkdir(parents=True, exist_ok=True)\\n tmp = STATE_PATH.with_name(STATE_PATH.name + \\\".tmp\\\")\\n tmp.write_text(state.model_dump_json(indent=2), encoding=\\\"utf-8\\\")\\n os.replace(tmp, STATE_PATH)\\n\\n\\ndef _rmtree(path: Path, ignore_errors: bool = True) -> None:\\n try:\\n shutil.rmtree(path, ignore_errors=ignore_errors)\\n except OSError:\\n pass\\n\\n\\ndef _delete_skill_dir_by_name(name: str, label: str) -> None:\\n \\\"\\\"\\\"Fail-closed prune of a no-longer-desired skill's directory: abort the whole run if removal\\n fails so we never silently leave a stale skill on disk. skill_dir() returns None for a\\n corrupt/tampered name, so rmtree never runs on an out-of-tree path.\\\"\\\"\\\"\\n dir_ = skill_dir(name)\\n if dir_ is None or not dir_.is_dir():\\n return\\n try:\\n shutil.rmtree(dir_)\\n except OSError as e:\\n sys.exit(f\\\"Could not remove skill directory {dir_} ({label}): {e}\\\")\\n\\n\\ndef git_skills_from_env(raw: str | None) -> list[GitSkill]:\\n \\\"\\\"\\\"Empty or missing env means \\\"no desired git skills\\\" (prune all git downloads).\\\"\\\"\\\"\\n if raw is None or not str(raw).strip():\\n return []\\n try:\\n data = json.loads(base64.b64decode(raw))\\n except (ValueError, json.JSONDecodeError) as e:\\n sys.exit(f\\\"AGENT_GIT_SKILLS is not valid base64-encoded JSON: {e}\\\")\\n if not isinstance(data, list):\\n sys.exit(\\\"AGENT_GIT_SKILLS must be a JSON list.\\\")\\n try:\\n return [GitSkill.model_validate(item) for item in data]\\n except ValidationError as e:\\n sys.exit(f\\\"Invalid AGENT_GIT_SKILLS entry: {e}\\\")\\n\\n\\ndef reconcile_git_skills(git_skills: list[GitSkill], state: SkillDownloaderState) -> list[str]:\\n \\\"\\\"\\\"Delete dirs for git skills no longer desired; prune them from state. Returns removed names.\\\"\\\"\\\"\\n desired_names = {g.name for g in git_skills}\\n removed: list[str] = []\\n for entry in state.downloaded_git_skills:\\n if entry.name in desired_names:\\n continue\\n # Fail-closed prune: a failed delete aborts the run rather than leaving a stale skill on disk.\\n # The name is dropped from tracking either way.\\n _delete_skill_dir_by_name(entry.name, entry.name)\\n removed.append(entry.name)\\n state.downloaded_git_skills = [\\n e for e in state.downloaded_git_skills if e.name in desired_names\\n ]\\n return removed\\n\\n\\ndef _run_git(\\n args: list[str], cwd: Path, input_text: str | None = None\\n) -> subprocess.CompletedProcess[str]:\\n \\\"\\\"\\\"Run a git subcommand non-interactively and fail-closed on error.\\n\\n Args are passed as a list (never a shell string) and git runs with a scrubbed environment:\\n ambient global/system config and credential helpers are ignored (so nothing can rewrite the URL\\n via insteadOf or inject creds) and prompting is disabled (so a private/typo repo fails fast\\n instead of hanging on a username prompt).\\\"\\\"\\\"\\n env = {\\n **os.environ,\\n \\\"GIT_TERMINAL_PROMPT\\\": \\\"0\\\", # never prompt for credentials — fail fast on a non-public repo\\n \\\"GIT_CONFIG_NOSYSTEM\\\": \\\"1\\\", # ignore /etc/gitconfig\\n \\\"GIT_CONFIG_GLOBAL\\\": \\\"/dev/null\\\", # ignore ~/.gitconfig (insteadOf rewrites, cred helpers)\\n \\\"GCM_INTERACTIVE\\\": \\\"never\\\",\\n }\\n try:\\n proc = subprocess.run(\\n [\\\"git\\\", *args],\\n cwd=os.fspath(cwd),\\n env=env,\\n input=input_text,\\n capture_output=True,\\n text=True,\\n timeout=GIT_CLONE_TIMEOUT_SECONDS,\\n )\\n except subprocess.TimeoutExpired:\\n raise GitSkillError(f\\\"git {args[0]} timed out after {GIT_CLONE_TIMEOUT_SECONDS}s\\\")\\n except OSError as e:\\n raise GitSkillError(f\\\"could not run git (is it installed in the sandbox?): {e}\\\")\\n if proc.returncode != 0:\\n detail = (proc.stderr or proc.stdout or \\\"\\\").strip()[-500:]\\n raise GitSkillError(f\\\"git {args[0]} failed (exit {proc.returncode}): {detail}\\\")\\n return proc\\n\\n\\ndef _parse_ls_remote_object_id(stdout: str) -> str | None:\\n \\\"\\\"\\\"Prefer a peeled `ref^{}` line (annotated tags), else the first object id.\\\"\\\"\\\"\\n first: str | None = None\\n for line in stdout.splitlines():\\n trimmed = line.strip()\\n if not trimmed:\\n continue\\n parts = trimmed.split(None, 1)\\n sha = parts[0]\\n name = parts[1] if len(parts) > 1 else \\\"\\\"\\n if name.endswith(\\\"^{}\\\"):\\n return sha\\n if first is None:\\n first = sha\\n return first\\n\\n\\ndef _resolve_object_id(url: str, ref: str, cwd: Path) -> str:\\n \\\"\\\"\\\"Resolve a mount ref (branch/tag/SHA) to an object id inside the sandbox.\\n\\n Full object ids are used as-is (`git ls-remote` does not advertise bare SHAs on\\n GitHub/GitLab). Branches and tags are resolved via `git ls-remote` so a branch tip\\n advances on later sandbox inits.\\n \\\"\\\"\\\"\\n if OBJECT_ID_RE.match(ref):\\n return ref\\n proc = _run_git([\\\"ls-remote\\\", url, ref], cwd=cwd)\\n oid = _parse_ls_remote_object_id(proc.stdout or \\\"\\\")\\n if oid is None:\\n raise GitSkillError(f\\\"git ls-remote returned no commit for {url} ref {ref}\\\")\\n return oid\\n\\n\\ndef _git_fetch_repo(\\n url: str, object_id: str, paths: list[str], parent_dir: Path\\n) -> Path:\\n \\\"\\\"\\\"Object-id-pinned, shallow, blob-filtered sparse checkout of `paths` into a fresh repo under\\n `parent_dir`, returning the repo working directory.\\n\\n When every requested path is the repo root (\\\"\\\") we can't sparse-restrict, so we do a full\\n shallow checkout (all blobs). Otherwise we cone-sparse to just the requested paths and use\\n --filter=blob:none so only those paths' blobs are fetched — the whole point of the approach.\\n Paths are fed to `git sparse-checkout set` via stdin so a path value can never be read as an\\n option/argument. Fetching by object id relies on the server allowing it, which github.com and\\n gitlab.com both do.\\\"\\\"\\\"\\n repo = parent_dir / \\\"repo\\\"\\n _run_git([\\\"init\\\", \\\"-q\\\", os.fspath(repo)], cwd=parent_dir)\\n _run_git([\\\"remote\\\", \\\"add\\\", \\\"origin\\\", url], cwd=repo)\\n\\n cone_dirs = [s for s in dict.fromkeys(paths) if s] # de-duped, root (\\\"\\\") dropped\\n want_full_tree = any(s == \\\"\\\" for s in paths)\\n if not want_full_tree and cone_dirs:\\n _run_git([\\\"sparse-checkout\\\", \\\"init\\\", \\\"--cone\\\"], cwd=repo)\\n _run_git(\\n [\\\"sparse-checkout\\\", \\\"set\\\", \\\"--stdin\\\"],\\n cwd=repo,\\n input_text=\\\"\\\".join(f\\\"{d}\\\\n\\\" for d in cone_dirs),\\n )\\n\\n fetch_args = [\\\"-c\\\", \\\"protocol.file.allow=never\\\", \\\"fetch\\\", \\\"-q\\\", \\\"--depth\\\", \\\"1\\\"]\\n if not want_full_tree:\\n fetch_args.append(\\\"--filter=blob:none\\\")\\n fetch_args += [\\\"origin\\\", object_id]\\n _run_git(fetch_args, cwd=repo)\\n\\n _run_git(\\n [\\\"-c\\\", \\\"advice.detachedHead=false\\\", \\\"checkout\\\", \\\"-q\\\", \\\"--detach\\\", \\\"FETCH_HEAD\\\"], cwd=repo\\n )\\n return repo\\n\\n\\ndef _installed_size_bytes(path: Path) -> int:\\n \\\"\\\"\\\"Total size of regular files under `path`, excluding the repo's .git dir and without following\\n symlinks (they're copied as links, not their targets), used to enforce GIT_SKILL_MAX_BYTES.\\\"\\\"\\\"\\n total = 0\\n for root, dirs, files in os.walk(path, followlinks=False):\\n if \\\".git\\\" in dirs:\\n dirs.remove(\\\".git\\\")\\n for name in files:\\n try:\\n total += (Path(root) / name).lstat().st_size\\n except OSError:\\n continue\\n return total\\n\\n\\ndef _install_git_skill(repo_root: Path, skill: GitSkill) -> None:\\n dest = skill_dir(skill.name)\\n if dest is None:\\n raise GitSkillError(f\\\"Git skill: unsafe directory name {skill.name!r}\\\")\\n repo_root_resolved = repo_root.resolve()\\n src = (repo_root / skill.path).resolve() if skill.path else repo_root_resolved\\n # Defense in depth: even though path is validated, ensure the resolved source stays within the\\n # cloned repo (guards against a symlinked directory component pointing outside the checkout).\\n if src != repo_root_resolved and not src.is_relative_to(repo_root_resolved):\\n raise GitSkillError(\\n f\\\"Git skill {skill.name}: path '{skill.path}' escapes the repository\\\"\\n )\\n if not src.is_dir():\\n raise GitSkillError(\\n f\\\"Git skill {skill.name}: path '{skill.path}' not found in repository\\\"\\n )\\n if _installed_size_bytes(src) > GIT_SKILL_MAX_BYTES:\\n raise GitSkillError(\\n f\\\"Git skill {skill.name}: content exceeds {GIT_SKILL_MAX_BYTES} bytes\\\"\\n )\\n # Stage the fresh copy into a temp sibling dir, then swap it in with an atomic rename. `dest` is\\n # only touched once the copy has fully succeeded, so a failed/partial copytree can never leave\\n # the skill missing (which would contradict the path the gateway advertises in the prompt) — the\\n # previously installed copy stays intact on failure. `.git` is excluded (only present for a repo-\\n # root skill) and symlinks are copied as links, never followed, so a link inside the repo can't\\n # pull external file contents into the skill dir at copy time.\\n staging = dest.with_name(f\\\".{dest.name}.new-{os.getpid()}\\\")\\n _rmtree(staging) # clear any leftover staging dir from an earlier aborted run\\n try:\\n shutil.copytree(src, staging, symlinks=True, ignore=shutil.ignore_patterns(\\\".git\\\"))\\n except (OSError, shutil.Error) as e:\\n # shutil.Error (a multi-file copy error, NOT an OSError subclass) must be caught here too so a\\n # bad copy is reported as a per-skill GitSkillError (WARNING + counted failure) instead of an\\n # uncaught traceback that aborts the whole run and skips save_state for the group.\\n _rmtree(staging)\\n raise GitSkillError(f\\\"Failed to stage git skill {skill.name}: {e}\\\")\\n try:\\n if dest.exists():\\n shutil.rmtree(dest)\\n # Same filesystem (both under SKILLS_ROOT), so this is an atomic rename; the window where the\\n # skill dir is absent shrinks from a full copy to a single near-instant rename.\\n os.replace(staging, dest)\\n except OSError as e:\\n _rmtree(staging)\\n raise GitSkillError(f\\\"Failed to install git skill {skill.name}: {e}\\\")\\n\\n\\ndef _mark_git_downloaded(\\n state: SkillDownloaderState, skill: GitSkill, object_id: str\\n) -> None:\\n \\\"\\\"\\\"Upsert a git skill's installed object id + path so the next run can skip when both match.\\\"\\\"\\\"\\n for entry in state.downloaded_git_skills:\\n if entry.name == skill.name:\\n entry.ref = object_id\\n entry.path = skill.path\\n return\\n state.downloaded_git_skills.append(\\n DownloadedGitSkill(name=skill.name, ref=object_id, path=skill.path)\\n )\\n\\n\\ndef _git_skill_already_installed(\\n state: SkillDownloaderState, skill: GitSkill, object_id: str\\n) -> bool:\\n \\\"\\\"\\\"True only when the skill is recorded at the resolved object id AND path AND its directory is\\n still present. Re-download otherwise (branch tip moved, path changed, first install, or the\\n dir was removed out-of-band) so the prompt never points at a missing/stale skill dir.\\\"\\\"\\\"\\n dir_ = skill_dir(skill.name)\\n if dir_ is None or not dir_.is_dir():\\n return False\\n return any(\\n e.name == skill.name and e.ref == object_id and e.path == skill.path\\n for e in state.downloaded_git_skills\\n )\\n\\n\\ndef download_git_skills(\\n git_skills: list[GitSkill], state: SkillDownloaderState\\n) -> int:\\n \\\"\\\"\\\"Ensure desired git skills are installed at their resolved object id, skipping any already\\n present at that pin so an unchanged tip no longer re-clones every run.\\n\\n Ref resolution (`git ls-remote`) runs here in the sandbox — the host never spawns git.\\n Failures are isolated per repo group and per skill so one bad repo/subdir doesn't prevent the\\n rest from being attempted; each failure is logged to stderr. Returns the number of skills that are\\n satisfied this run — freshly installed OR already present at the resolved pin (never raises).\\n The caller (run_git_download) compares this to the number requested and exits non-zero if any are\\n missing, so the overall behaviour is fail-closed.\\\"\\\"\\\"\\n SKILLS_ROOT.mkdir(parents=True, exist_ok=True)\\n\\n # Resolve mount refs → object ids, then skip skills already installed at that pin.\\n pending: list[tuple[GitSkill, str]] = []\\n satisfied = 0\\n for skill in git_skills:\\n try:\\n object_id = _resolve_object_id(skill.url, skill.ref, SKILLS_ROOT)\\n except GitSkillError as e:\\n print(f\\\"WARNING: {e} (skill: {skill.name})\\\", file=sys.stderr)\\n continue\\n if _git_skill_already_installed(state, skill, object_id):\\n satisfied += 1\\n else:\\n pending.append((skill, object_id))\\n\\n # Group pending by (repo, object id) so a repo shared by several skills is cloned once and its\\n # needed paths fetched together in a single sparse checkout.\\n skills_by_repo: dict[tuple[str, str], list[tuple[GitSkill, str]]] = {}\\n for skill, object_id in pending:\\n skills_by_repo.setdefault((skill.url, object_id), []).append((skill, object_id))\\n\\n for (url, object_id), group in skills_by_repo.items():\\n try:\\n with tempfile.TemporaryDirectory(\\n prefix=\\\".git-skill-dl-\\\",\\n dir=os.fspath(SKILLS_ROOT),\\n ignore_cleanup_errors=True,\\n ) as tmp_str:\\n repo_root = _git_fetch_repo(\\n url,\\n object_id,\\n [s.path for s, _ in group],\\n Path(tmp_str),\\n )\\n # Install each skill independently so a bad path only skips that one skill.\\n for skill, oid in group:\\n try:\\n _install_git_skill(repo_root, skill)\\n _mark_git_downloaded(state, skill, oid)\\n satisfied += 1\\n except GitSkillError as e:\\n print(f\\\"WARNING: {e}\\\", file=sys.stderr)\\n except GitSkillError as e:\\n # Collect (don't abort-on-first) so EVERY failing skill is reported before the run exits;\\n # run_git_download turns a non-zero failed count into a non-zero exit (fail-closed), which\\n # fails the whole agent request. We still don't purge a previously installed copy here, so\\n # a reused sandbox keeps the old files on disk even though this run is about to fail.\\n names = \\\", \\\".join(s.name for s, _ in group)\\n print(f\\\"WARNING: {e} (skills: {names})\\\", file=sys.stderr)\\n save_state(state)\\n return satisfied\\n\\n\\ndef run_git_download() -> None:\\n git_skills = git_skills_from_env(os.environ.get(\\\"AGENT_GIT_SKILLS\\\"))\\n state = load_state()\\n removed = reconcile_git_skills(git_skills, state)\\n save_state(state) # persist pruning even when there is nothing to download\\n\\n if not git_skills:\\n if removed:\\n print(f\\\"Removed {len(removed)} git skill(s) from disk. (AGENT_GIT_SKILLS empty.)\\\")\\n return\\n\\n satisfied = download_git_skills(git_skills, state)\\n failed = len(git_skills) - satisfied\\n suffix = f\\\" Removed {len(removed)} stale git skill(s).\\\" if removed else \\\"\\\"\\n if failed:\\n # Fail-closed: a requested skill that couldn't be installed exits non-zero so the gateway\\n # (ensureExecSuccess) fails the whole agent request, matching the fail-closed SKILL.md fetch.\\n # Per-skill WARNINGs above list exactly what failed.\\n sys.exit(\\n f\\\"Failed to install {failed}/{len(git_skills)} git skill(s); see warnings above.{suffix}\\\"\\n )\\n print(f\\\"Ensured {satisfied} git skill(s) (downloaded or already up to date).{suffix}\\\")\\n\\n\\ndef main() -> None:\\n run_git_download()\\n\\n\\nif __name__ == \\\"__main__\\\":\\n main()\\n\",\n} as const;\n"],"mappings":";AACO,IAAM,iBAAiB;AAAA,EAC5B,WAAW;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EACX,eAAe;AACjB;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@truefoundry/trueforge-core",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "TrueForge Agent Harness library.",
5
5
  "author": "TrueFoundry",
6
6
  "license": "MIT",