diff --git a/core/src/tools/skill/run_skill_script_tool.ts b/core/src/tools/skill/run_skill_script_tool.ts index 2f7845bbb..e8a7fd597 100644 --- a/core/src/tools/skill/run_skill_script_tool.ts +++ b/core/src/tools/skill/run_skill_script_tool.ts @@ -33,9 +33,20 @@ export enum RunSkillScriptErrorCode { SKILL_NOT_FOUND = 'SKILL_NOT_FOUND', SCRIPT_NOT_FOUND = 'SCRIPT_NOT_FOUND', NO_CODE_EXECUTOR = 'NO_CODE_EXECUTOR', + UNSUPPORTED_SCRIPT_LANGUAGE = 'UNSUPPORTED_SCRIPT_LANGUAGE', EXECUTION_ERROR = 'EXECUTION_ERROR', } +/** Script extensions `buildWrapperCode` can emit a launcher for. */ +const SUPPORTED_SCRIPT_EXTENSIONS = [ + '.js', + '.py', + '.sh', + '.ps1', + '.bat', + '.cmd', +] as const; + @experimental export class RunSkillScriptTool extends BaseTool { constructor(private toolset: SkillToolset) { @@ -59,7 +70,8 @@ export class RunSkillScriptTool extends BaseTool { script_path: { type: Type.STRING, description: - "The relative path to the script (e.g., 'scripts/setup.js').", + "The relative path to the script (e.g., 'scripts/setup.js'). " + + `Supported extensions: ${SUPPORTED_SCRIPT_EXTENSIONS.join(', ')}.`, }, args: { type: Type.OBJECT, @@ -144,12 +156,23 @@ export class RunSkillScriptTool extends BaseTool { }; } + const ext = path.extname(scriptPath); + const language = getScriptLanguageByExtension(ext); + const code = buildWrapperCode(scriptPath, language); + if (code === undefined) { + return { + error: + `Script '${scriptPath}' has unsupported extension '${ext}'. ` + + `Skill scripts must be one of: ${SUPPORTED_SCRIPT_EXTENSIONS.join(', ')}.`, + errorCode: RunSkillScriptErrorCode.UNSUPPORTED_SCRIPT_LANGUAGE, + }; + } + try { - const language = getScriptLanguageByExtension(path.extname(scriptPath)); const result = await codeExecutor.executeCode({ invocationContext: toolContext.invocationContext, codeExecutionInput: { - code: buildWrapperCode(scriptPath, language), + code, inputFiles: getSkillResourceFiles(skill), language, args: scriptArgs, @@ -169,15 +192,17 @@ export class RunSkillScriptTool extends BaseTool { } } +/** + * Builds the launcher a code executor runs to invoke the script, or + * `undefined` when the tool cannot launch the language. + */ function buildWrapperCode( scriptPath: string, language: CodeExecutionLanguage, -): string { +): string | undefined { switch (language) { case CodeExecutionLanguage.JAVASCRIPT: return `require('./${scriptPath}');`; - case CodeExecutionLanguage.TYPESCRIPT: - return `require('ts-node/register');\nrequire('./${scriptPath}');`; case CodeExecutionLanguage.PYTHON: return `import runpy\nrunpy.run_path('./${scriptPath}', run_name='__main__')`; case CodeExecutionLanguage.SHELL: @@ -187,7 +212,7 @@ function buildWrapperCode( case CodeExecutionLanguage.WINDOWS_CMD: return `call .\\${scriptPath.replace(/\//g, '\\\\')} %*`; default: - throw new Error(`Unsupported wrapper language: ${language}`); + return undefined; } } diff --git a/core/test/tools/skills/run_skill_script_tool_test.ts b/core/test/tools/skills/run_skill_script_tool_test.ts index 27d1fd3fe..3c853db40 100644 --- a/core/test/tools/skills/run_skill_script_tool_test.ts +++ b/core/test/tools/skills/run_skill_script_tool_test.ts @@ -89,6 +89,23 @@ describe('RunSkillScriptTool', () => { }, }; + const unsupportedLanguageSkill: Skill = { + frontmatter: {name: 'ts-skill', description: 'Ships non-runnable scripts'}, + instructions: 'Test instructions', + resources: { + scripts: { + 'setup.ts': {src: 'const x: number = 1; console.log(x);'}, + 'notes.txt': {src: 'not a script'}, + }, + }, + }; + + const pythonSkill: Skill = { + frontmatter: {name: 'python-skill', description: 'Ships a Python script'}, + instructions: 'Test instructions', + resources: {scripts: {'build.py': {src: 'print("build")'}}}, + }; + it('returns error if skill name is missing', async () => { const toolset = new SkillToolset([mockSkill]); const tool = new RunSkillScriptTool(toolset); @@ -247,6 +264,120 @@ describe('RunSkillScriptTool', () => { expect(materializeFiles).toHaveBeenCalledWith([testFile]); }); + it('declares the supported script extensions to the model', () => { + const tool = new RunSkillScriptTool(new SkillToolset([mockSkill])); + + const declaration = tool._getDeclaration(); + + expect( + declaration.parameters?.properties?.['script_path'].description, + ).toBe( + "The relative path to the script (e.g., 'scripts/setup.js'). " + + 'Supported extensions: .js, .py, .sh, .ps1, .bat, .cmd.', + ); + }); + + it('refuses a TypeScript script before reaching the executor', async () => { + const mockExecutor = new MockCodeExecutor(); + const toolset = new SkillToolset([unsupportedLanguageSkill], { + codeExecutor: mockExecutor, + }); + const tool = new RunSkillScriptTool(toolset); + + const result = (await tool.runAsync({ + args: {skill_name: 'ts-skill', script_path: 'scripts/setup.ts'}, + toolContext: createMockContext(), + })) as ToolErrorResponse; + + expect(result).toEqual({ + error: + "Script 'scripts/setup.ts' has unsupported extension '.ts'. " + + 'Skill scripts must be one of: .js, .py, .sh, .ps1, .bat, .cmd.', + errorCode: RunSkillScriptErrorCode.UNSUPPORTED_SCRIPT_LANGUAGE, + }); + expect(mockExecutor.executeCodeParams).toBeUndefined(); + }); + + it('refuses a TypeScript script requested without the scripts/ prefix', async () => { + const mockExecutor = new MockCodeExecutor(); + const toolset = new SkillToolset([unsupportedLanguageSkill], { + codeExecutor: mockExecutor, + }); + const tool = new RunSkillScriptTool(toolset); + + const result = (await tool.runAsync({ + args: {skill_name: 'ts-skill', script_path: 'setup.ts'}, + toolContext: createMockContext(), + })) as ToolErrorResponse; + + expect(result).toEqual({ + error: + "Script 'setup.ts' has unsupported extension '.ts'. " + + 'Skill scripts must be one of: .js, .py, .sh, .ps1, .bat, .cmd.', + errorCode: RunSkillScriptErrorCode.UNSUPPORTED_SCRIPT_LANGUAGE, + }); + expect(mockExecutor.executeCodeParams).toBeUndefined(); + }); + + it('refuses an extension that maps to no language', async () => { + const mockExecutor = new MockCodeExecutor(); + const toolset = new SkillToolset([unsupportedLanguageSkill], { + codeExecutor: mockExecutor, + }); + const tool = new RunSkillScriptTool(toolset); + + const result = (await tool.runAsync({ + args: {skill_name: 'ts-skill', script_path: 'scripts/notes.txt'}, + toolContext: createMockContext(), + })) as ToolErrorResponse; + + expect(result).toEqual({ + error: + "Script 'scripts/notes.txt' has unsupported extension '.txt'. " + + 'Skill scripts must be one of: .js, .py, .sh, .ps1, .bat, .cmd.', + errorCode: RunSkillScriptErrorCode.UNSUPPORTED_SCRIPT_LANGUAGE, + }); + expect(mockExecutor.executeCodeParams).toBeUndefined(); + }); + + it('still executes a Python script with the runpy wrapper', async () => { + const mockExecutor = new MockCodeExecutor(); + const toolset = new SkillToolset([pythonSkill], { + codeExecutor: mockExecutor, + }); + const tool = new RunSkillScriptTool(toolset); + + await tool.runAsync({ + args: {skill_name: 'python-skill', script_path: 'scripts/build.py'}, + toolContext: createMockContext(), + }); + + expect(mockExecutor.executeCodeParams?.codeExecutionInput.code).toBe( + "import runpy\nrunpy.run_path('./scripts/build.py', run_name='__main__')", + ); + expect(mockExecutor.executeCodeParams?.codeExecutionInput.language).toBe( + CodeExecutionLanguage.PYTHON, + ); + }); + + it('still executes a Shell script with the source wrapper', async () => { + const mockExecutor = new MockCodeExecutor(); + const toolset = new SkillToolset([mockSkill], {codeExecutor: mockExecutor}); + const tool = new RunSkillScriptTool(toolset); + + await tool.runAsync({ + args: {skill_name: 'test-skill', script_path: 'scripts/run.sh'}, + toolContext: createMockContext(), + }); + + expect(mockExecutor.executeCodeParams?.codeExecutionInput.code).toBe( + 'source ./scripts/run.sh "$@"', + ); + expect(mockExecutor.executeCodeParams?.codeExecutionInput.language).toBe( + CodeExecutionLanguage.SHELL, + ); + }); + describe('error codes', () => { it('exposes stable string values for the error-code enum', () => { // The error-code string values are part of the tool's response contract @@ -263,5 +394,11 @@ describe('RunSkillScriptTool', () => { expect(RunSkillScriptErrorCode.NO_CODE_EXECUTOR).toBe('NO_CODE_EXECUTOR'); expect(RunSkillScriptErrorCode.EXECUTION_ERROR).toBe('EXECUTION_ERROR'); }); + + it('exposes a stable string value for the unsupported-language code', () => { + expect(RunSkillScriptErrorCode.UNSUPPORTED_SCRIPT_LANGUAGE).toBe( + 'UNSUPPORTED_SCRIPT_LANGUAGE', + ); + }); }); }); diff --git a/tests/integration/tools/run_skill_script_tool_test.ts b/tests/integration/tools/run_skill_script_tool_test.ts index 792fd11b0..e5ffc1749 100644 --- a/tests/integration/tools/run_skill_script_tool_test.ts +++ b/tests/integration/tools/run_skill_script_tool_test.ts @@ -8,6 +8,7 @@ import { CodeExecutionResult, Context, InvocationContext, + RunSkillScriptErrorCode, RunSkillScriptTool, Skill, SkillToolset, @@ -45,6 +46,9 @@ describe('RunSkillScriptTool Integration with UnsafeLocalCodeExecutor', () => { 'hello.sh': { src: 'echo "hello from skill sh"', }, + 'hello.ts': { + src: 'const msg: string = "hello from skill ts"; console.log(msg);', + }, 'fail.js': { src: 'console.error("skill js error"); process.exit(1);', }, @@ -347,4 +351,27 @@ describe('RunSkillScriptTool Integration with UnsafeLocalCodeExecutor', () => { await fs.unlink(targetFile); await fs.unlink(fullPath); }); + + it('refuses a TypeScript skill script instead of running it', async () => { + const executor = new UnsafeLocalCodeExecutor(); + const toolset = new SkillToolset([testSkill], {codeExecutor: executor}); + const tool = new RunSkillScriptTool(toolset); + + const result = await tool.runAsync({ + args: { + skill_name: 'test-skill', + script_path: 'scripts/hello.ts', + }, + toolContext: createMockContext(), + }); + + expect(result).toEqual({ + error: + "Script 'scripts/hello.ts' has unsupported extension '.ts'. " + + 'Skill scripts must be one of: .js, .py, .sh, .ps1, .bat, .cmd.', + errorCode: RunSkillScriptErrorCode.UNSUPPORTED_SCRIPT_LANGUAGE, + }); + expect(result).not.toHaveProperty('stdout'); + expect(result).not.toHaveProperty('stderr'); + }); });