Skip to content

Commit 944b00d

Browse files
committed
feat: unsafe local code executor
1 parent 3deda16 commit 944b00d

File tree

5 files changed

+283
-4
lines changed

5 files changed

+283
-4
lines changed

core/src/code_executors/base_code_executor.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,16 +72,18 @@ export abstract class BaseCodeExecutor {
7272

7373
/**
7474
* The list of the enclosing delimiters to identify the code blocks.
75-
* For example, the delimiter('```python\\n', '\\n```') can be used to
76-
* identify code blocks with the following format::
75+
* For example, the delimiter('```javascript\\n', '\\n```') can be used to
76+
* identify code blocks with the following format:
7777
*
78-
* ```python
79-
* print("hello")
78+
* ```javascript
79+
* console.log("hello")
8080
* ```
8181
*/
8282
codeBlockDelimiters: Array<[string, string]> = [
8383
['```tool_code\n', '\n```'],
8484
['```python\n', '\n```'],
85+
['```javascript\n', '\n```'],
86+
['```typescript\n', '\n```'],
8587
];
8688

8789
/**
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {spawn} from 'child_process';
8+
import * as fs from 'node:fs/promises';
9+
import * as os from 'node:os';
10+
import * as path from 'node:path';
11+
import {BaseCodeExecutor, ExecuteCodeParams} from './base_code_executor.js';
12+
import {CodeExecutionResult} from './code_execution_utils.js';
13+
14+
/**
15+
* Options for UnsafeLocalCodeExecutor.
16+
*/
17+
export interface UnsafeLocalCodeExecutorOptions {
18+
/**
19+
* Timeout for code execution in seconds. Default is 30.
20+
*/
21+
timeoutSeconds?: number;
22+
/**
23+
* The command to run the code. Default is `process.execPath` (Node.js).
24+
*/
25+
commandPath?: string;
26+
}
27+
28+
async function createTempJsFile(code: string): Promise<string> {
29+
const tempDir = path.join(
30+
os.tmpdir(),
31+
'adk_js_unsafe_code_executor',
32+
Date.now().toString(),
33+
);
34+
await fs.mkdir(tempDir, {recursive: true});
35+
const filePath = path.join(tempDir, 'script.js');
36+
await fs.writeFile(filePath, code);
37+
38+
return filePath;
39+
}
40+
41+
/**
42+
* A code executor that unsafely executes code in the local context.
43+
* By default, it executes JavaScript code using the current Node.js executable.
44+
*/
45+
export class UnsafeLocalCodeExecutor extends BaseCodeExecutor {
46+
private readonly timeoutSeconds: number;
47+
private readonly commandPath: string;
48+
49+
constructor(options: UnsafeLocalCodeExecutorOptions = {}) {
50+
super();
51+
this.timeoutSeconds = options.timeoutSeconds ?? 30;
52+
this.commandPath = options.commandPath ?? process.execPath;
53+
this.stateful = false;
54+
this.optimizeDataFile = false;
55+
}
56+
57+
async executeCode(params: ExecuteCodeParams): Promise<CodeExecutionResult> {
58+
const {code} = params.codeExecutionInput;
59+
60+
let filePath: string | undefined;
61+
try {
62+
filePath = await createTempJsFile(code);
63+
64+
return await new Promise<CodeExecutionResult>((resolve) => {
65+
const child = spawn(this.commandPath, [filePath!], {
66+
timeout: this.timeoutSeconds * 1000,
67+
killSignal: 'SIGKILL',
68+
});
69+
70+
let stdout = '';
71+
let stderr = '';
72+
73+
if (child.stdout) {
74+
child.stdout.on('data', (data) => {
75+
stdout += data.toString();
76+
});
77+
}
78+
79+
if (child.stderr) {
80+
child.stderr.on('data', (data) => {
81+
stderr += data.toString();
82+
});
83+
}
84+
85+
child.on('error', (err) => {
86+
stderr += `Process error: ${err.message}\n`;
87+
});
88+
89+
child.on('close', (exitCode, signal) => {
90+
if (signal === 'SIGKILL' || signal === 'SIGTERM') {
91+
stderr += `\nCode execution timed out after ${this.timeoutSeconds} seconds.`;
92+
}
93+
resolve({
94+
stdout,
95+
stderr,
96+
outputFiles: [],
97+
});
98+
});
99+
});
100+
} finally {
101+
if (filePath) {
102+
await fs.rm(filePath, {recursive: true, force: true});
103+
}
104+
}
105+
}
106+
}

core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export type {ExecutorContext} from './a2a/executor_context.js';
2626
export {FileArtifactService} from './artifacts/file_artifact_service.js';
2727
export {GcsArtifactService} from './artifacts/gcs_artifact_service.js';
2828
export {getArtifactServiceFromUri} from './artifacts/registry.js';
29+
export {UnsafeLocalCodeExecutor} from './code_executors/unsafe_local_code_executor.js';
2930
export * from './common.js';
3031
export {DatabaseSessionService} from './sessions/database_session_service.js';
3132
export {getSessionServiceFromUri} from './sessions/registry.js';
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import {describe, expect, it} from 'vitest';
2+
import {
3+
BaseCodeExecutor,
4+
ExecuteCodeParams,
5+
isBaseCodeExecutor,
6+
} from '../../src/code_executors/base_code_executor.js';
7+
import {CodeExecutionResult} from '../../src/code_executors/code_execution_utils.js';
8+
9+
class TestExecutor extends BaseCodeExecutor {
10+
async executeCode(_params: ExecuteCodeParams): Promise<CodeExecutionResult> {
11+
return {stdout: '', stderr: '', outputFiles: []};
12+
}
13+
}
14+
15+
describe('BaseCodeExecutor', () => {
16+
it('should have default values', () => {
17+
const executor = new TestExecutor();
18+
expect(executor.optimizeDataFile).toBe(false);
19+
expect(executor.stateful).toBe(false);
20+
expect(executor.errorRetryAttempts).toBe(2);
21+
});
22+
23+
it('should have default delimiters', () => {
24+
const executor = new TestExecutor();
25+
const bt = String.fromCharCode(96);
26+
const threeBt = bt + bt + bt;
27+
28+
expect(executor.codeBlockDelimiters).toEqual([
29+
[threeBt + 'tool_code\n', '\n' + threeBt],
30+
[threeBt + 'python\n', '\n' + threeBt],
31+
[threeBt + 'javascript\n', '\n' + threeBt],
32+
[threeBt + 'typescript\n', '\n' + threeBt],
33+
]);
34+
35+
expect(executor.executionResultDelimiters).toEqual([
36+
threeBt + 'tool_output\n',
37+
'\n' + threeBt,
38+
]);
39+
});
40+
41+
it('should identify instances', () => {
42+
const executor = new TestExecutor();
43+
expect(isBaseCodeExecutor(executor)).toBe(true);
44+
});
45+
46+
it('should reject non-instances', () => {
47+
expect(isBaseCodeExecutor({})).toBe(false);
48+
expect(isBaseCodeExecutor(null)).toBe(false);
49+
expect(isBaseCodeExecutor(undefined)).toBe(false);
50+
51+
// Test with symbol
52+
const objWithSymbol = {};
53+
Object.defineProperty(
54+
objWithSymbol,
55+
Symbol.for('google.adk.baseCodeExecutor'),
56+
{
57+
value: true,
58+
enumerable: true,
59+
},
60+
);
61+
expect(isBaseCodeExecutor(objWithSymbol)).toBe(true);
62+
});
63+
});
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {
8+
ExecuteCodeParams,
9+
InvocationContext,
10+
LlmAgent,
11+
PluginManager,
12+
UnsafeLocalCodeExecutor,
13+
createSession,
14+
} from '@google/adk';
15+
import {beforeEach, describe, expect, it} from 'vitest';
16+
17+
function createMockInvocationContext(): InvocationContext {
18+
const agent = new LlmAgent({
19+
name: 'test_agent',
20+
model: 'gemini-2.5-flash',
21+
});
22+
23+
return new InvocationContext({
24+
invocationId: 'test-invocation',
25+
agent,
26+
session: createSession({
27+
id: 'test-session',
28+
events: [],
29+
appName: 'test-app',
30+
userId: 'test-user',
31+
}),
32+
pluginManager: new PluginManager([]),
33+
});
34+
}
35+
36+
describe('UnsafeLocalCodeExecutor', () => {
37+
let executor: UnsafeLocalCodeExecutor;
38+
const invocationContext = createMockInvocationContext();
39+
40+
beforeEach(() => {
41+
executor = new UnsafeLocalCodeExecutor();
42+
});
43+
44+
it('should execute code and return stdout', async () => {
45+
const params: ExecuteCodeParams = {
46+
invocationContext,
47+
codeExecutionInput: {
48+
code: 'console.log("Hello, World!");',
49+
inputFiles: [],
50+
},
51+
};
52+
53+
const result = await executor.executeCode(params);
54+
55+
expect(result.stdout).toContain('Hello, World!');
56+
expect(result.stderr).toBe('');
57+
});
58+
59+
it('should capture stderr', async () => {
60+
const params: ExecuteCodeParams = {
61+
invocationContext,
62+
codeExecutionInput: {
63+
code: 'console.error("An error occurred");',
64+
inputFiles: [],
65+
},
66+
};
67+
68+
const result = await executor.executeCode(params);
69+
70+
expect(result.stderr).toContain('An error occurred');
71+
});
72+
73+
it('should handle execution errors', async () => {
74+
const params: ExecuteCodeParams = {
75+
invocationContext,
76+
codeExecutionInput: {
77+
code: 'throw new Error("Fatal error");',
78+
inputFiles: [],
79+
},
80+
};
81+
82+
const result = await executor.executeCode(params);
83+
84+
expect(result.stderr).toContain('Fatal error');
85+
});
86+
87+
it('should respect timeout', async () => {
88+
// Create executor with 1 second timeout
89+
const shortTimeoutExecutor = new UnsafeLocalCodeExecutor({
90+
timeoutSeconds: 1,
91+
});
92+
93+
const params: ExecuteCodeParams = {
94+
invocationContext,
95+
codeExecutionInput: {
96+
code: 'setTimeout(() => {}, 5000);', // Sleep for 5 seconds
97+
inputFiles: [],
98+
},
99+
};
100+
101+
const result = await shortTimeoutExecutor.executeCode(params);
102+
103+
expect(result.stderr).toContain(
104+
'Code execution timed out after 1 seconds.',
105+
);
106+
});
107+
});

0 commit comments

Comments
 (0)