Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 32 additions & 7 deletions core/src/tools/skill/run_skill_script_tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand All @@ -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;
}
}

Expand Down
137 changes: 137 additions & 0 deletions core/test/tools/skills/run_skill_script_tool_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -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',
);
});
});
});
27 changes: 27 additions & 0 deletions tests/integration/tools/run_skill_script_tool_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
CodeExecutionResult,
Context,
InvocationContext,
RunSkillScriptErrorCode,
RunSkillScriptTool,
Skill,
SkillToolset,
Expand Down Expand Up @@ -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);',
},
Expand Down Expand Up @@ -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');
});
});