RFC-034: JavaScript Eval Tool
RFC-034: JavaScript Eval Tool
Document Information
- RFC ID: RFC-034
- Related PRD: FEAT-034 (JavaScript Eval Tool)
- Related Architecture: RFC-000 (Overall Architecture)
- Related RFC: RFC-004 (Tool System)
- Created: 2026-03-01
- Last Updated: 2026-03-01
- Status: Draft
- Author: TBD
Overview
Background
OneClaw’s AI agent has access to pre-defined JS tools (file read/write, HTTP, time) and Kotlin built-in tools (webfetch, browser, exec, etc.). However, the agent lacks the ability to write and execute arbitrary code on the fly. When the agent needs to perform a calculation, data transformation, or algorithmic task, it currently has no way to run custom logic beyond what pre-defined tools offer.
The project already includes QuickJS as a dependency and has a mature JsExecutionEngine that manages QuickJS lifecycle, bridge injection, memory limits, and timeout enforcement. Adding a js_eval tool simply wraps this engine in a new built-in tool that accepts raw JS source code as a parameter.
Goals
- Implement
JsEvalTool.ktas a Kotlin built-in tool intool/builtin/ - Accept JavaScript source code as a string parameter and execute it via
JsExecutionEngine - Support both simple expression evaluation and
main()function entry point - Reuse all existing QuickJS bridges (console, fs, fetch, time, lib)
- Enforce timeout and memory limits via existing
JsExecutionEnginemechanisms - Register the tool in
ToolModule
Non-Goals
- Persistent JS context across multiple calls
- Structured input data parameters (beyond the code string)
- Custom bridge injection per-call
- npm/Node.js module support
- TypeScript compilation
Technical Design
Architecture Overview
+------------------------------------------------------------------+
| Chat Layer (RFC-001) |
| SendMessageUseCase |
| | |
| | tool call: js_eval(code="function main() { ... }") |
| v |
+------------------------------------------------------------------+
| Tool Execution Engine (RFC-004) |
| executeTool(name, params, availableToolIds) |
| | |
| v |
| +---------------------------------------------------------------+|
| | ToolRegistry ||
| | +--------------------+ ||
| | | js_eval | Kotlin built-in [NEW] ||
| | | (JsEvalTool.kt) | ||
| | +---------+----------+ ||
| | | ||
| | v ||
| | +--------------------------------------------------------+ ||
| | | JsExecutionEngine (existing) | ||
| | | 1. Create fresh QuickJS context | ||
| | | 2. Set memory limits (16MB heap, 1MB stack) | ||
| | | 3. Inject bridges (console, fs, fetch, time, lib) | ||
| | | 4. Wrap code with entry point detection | ||
| | | 5. Evaluate code | ||
| | | 6. Return result | ||
| | +--------------------------------------------------------+ ||
| +---------------------------------------------------------------+|
+------------------------------------------------------------------+
Core Components
New:
JsEvalTool– Kotlin built-in tool that accepts JS code and delegates toJsExecutionEngine
Modified:
ToolModule– RegisterJsEvalToolas a Kotlin built-in tool
Reused (unchanged):
JsExecutionEngine– Existing QuickJS execution engine- All bridge classes (
ConsoleBridge,FsBridge,FetchBridge,TimeBridge,LibraryBridge)
Detailed Design
Directory Structure (New & Changed Files)
app/src/main/
├── kotlin/com/oneclaw/shadow/
│ ├── tool/
│ │ └── builtin/
│ │ ├── JsEvalTool.kt # NEW
│ │ ├── ExecTool.kt # unchanged
│ │ ├── WebfetchTool.kt # unchanged
│ │ ├── BrowserTool.kt # unchanged
│ │ ├── LoadSkillTool.kt # unchanged
│ │ ├── CreateScheduledTaskTool.kt # unchanged
│ │ └── CreateAgentTool.kt # unchanged
│ └── di/
│ └── ToolModule.kt # MODIFIED
app/src/test/kotlin/com/oneclaw/shadow/
└── tool/
└── builtin/
└── JsEvalToolTest.kt # NEW
JsEvalTool
/**
* Located in: tool/builtin/JsEvalTool.kt
*
* Kotlin built-in tool that executes arbitrary JavaScript code
* in a sandboxed QuickJS environment via JsExecutionEngine.
* Useful for computation, data processing, and algorithmic tasks.
*/
class JsEvalTool(
private val jsExecutionEngine: JsExecutionEngine,
private val envVarStore: EnvironmentVariableStore
) : Tool {
companion object {
private const val DEFAULT_TIMEOUT_SECONDS = 30
private const val MAX_TIMEOUT_SECONDS = 120
}
override val definition = ToolDefinition(
name = "js_eval",
description = "Execute JavaScript code in a sandboxed environment and return the result. " +
"Useful for computation, data processing, math, string manipulation, and algorithmic tasks. " +
"If the code defines a main() function, it will be called and its return value used as the result. " +
"Otherwise, the value of the last expression is returned. " +
"Objects and arrays are JSON-serialized in the result.",
parametersSchema = ToolParametersSchema(
properties = mapOf(
"code" to ToolParameter(
type = "string",
description = "The JavaScript source code to execute"
),
"timeout_seconds" to ToolParameter(
type = "integer",
description = "Maximum execution time in seconds. Default: 30, Max: 120"
)
),
required = listOf("code")
),
requiredPermissions = emptyList(),
timeoutSeconds = MAX_TIMEOUT_SECONDS + 5
)
override suspend fun execute(parameters: Map<String, Any?>): ToolResult {
// 1. Parse and validate parameters
val code = parameters["code"]?.toString()
if (code.isNullOrBlank()) {
return ToolResult.error(
"validation_error",
"Parameter 'code' is required and cannot be empty"
)
}
val timeoutSeconds = parseIntParam(parameters["timeout_seconds"])
?.coerceIn(1, MAX_TIMEOUT_SECONDS)
?: DEFAULT_TIMEOUT_SECONDS
// 2. Wrap code to support both expression and main() patterns
val wrappedCode = wrapCode(code)
// 3. Execute via JsExecutionEngine
return jsExecutionEngine.executeFromSource(
jsSource = wrappedCode,
toolName = "js_eval",
functionName = null, // entry point handled by wrapper
params = emptyMap(),
env = envVarStore.getAll(),
timeoutSeconds = timeoutSeconds
)
}
/**
* Wrap user code to detect and call main() if defined,
* otherwise evaluate the code as-is (last expression = result).
*
* The JsExecutionEngine already wraps code in an async context
* and handles result serialization, so we just need to add
* the main() detection logic.
*/
private fun wrapCode(code: String): String {
return """
$code
if (typeof main === 'function') {
main();
}
""".trimIndent()
}
private fun parseIntParam(value: Any?): Int? {
return when (value) {
is Int -> value
is Long -> value.toInt()
is Double -> value.toInt()
is Number -> value.toInt()
is String -> value.toIntOrNull()
else -> null
}
}
}
How the Wrapper Integrates with JsExecutionEngine
The JsExecutionEngine.executeFromSource() already wraps the JS code in:
// (fetch wrapper)
// (lib wrapper)
<user code here>
const __params__ = JSON.parse(...);
const __result__ = await execute(__params__);
// result serialization
Since js_eval does not define an execute() function, JsExecutionEngine will call execute(__params__) which will fail. To avoid this, JsEvalTool uses functionName = null and the engine defaults to calling execute(). We need to ensure the wrapper code handles the case where no execute() function is defined.
Option A (simpler): Override by providing a wrapper that defines execute() itself:
private fun wrapCode(code: String): String {
return """
async function execute(params) {
$code
if (typeof main === 'function') {
return await main();
}
}
""".trimIndent()
}
This approach wraps the user’s code inside an execute() function body, so JsExecutionEngine’s existing wrapper calls it naturally. If the user defines main(), it is called. Otherwise, the function body evaluates the code and returns undefined (but any side effects like console.log still work).
However, for expression-style code (e.g., 2 + 2), we need the expression value to be returned. Revised wrapper:
private fun wrapCode(code: String): String {
return """
async function execute(params) {
const __eval_fn__ = new Function(`
$code
`);
// Check if user code defines main()
const __module__ = {};
const __check_fn__ = new Function('module', `
$code
if (typeof main === 'function') { module.main = main; }
`);
__check_fn__(__module__);
if (__module__.main) {
return await __module__.main();
}
// For expression-style code, use eval to get the last expression value
return eval($codeAsString);
}
""".trimIndent()
}
This is getting complex. Simplest correct approach: always wrap in execute() and use eval() for the user code, then check for main():
private fun wrapCode(code: String): String {
// Escape the code for embedding as a JS string
val escapedCode = code
.replace("\\", "\\\\")
.replace("`", "\\`")
.replace("\$", "\\\$")
return """
async function execute(params) {
const __result__ = eval(`$escapedCode`);
if (typeof main === 'function') {
return await main();
}
return __result__;
}
""".trimIndent()
}
Final Implementation (Recommended)
After analyzing JsExecutionEngine’s wrapper code, the simplest approach is:
class JsEvalTool(
private val jsExecutionEngine: JsExecutionEngine,
private val envVarStore: EnvironmentVariableStore
) : Tool {
companion object {
private const val DEFAULT_TIMEOUT_SECONDS = 30
private const val MAX_TIMEOUT_SECONDS = 120
}
override val definition = ToolDefinition(
name = "js_eval",
description = "Execute JavaScript code in a sandboxed environment and return the result. " +
"Useful for computation, data processing, math, string manipulation, and algorithmic tasks. " +
"If the code defines a main() function, it will be called and its return value used as the result. " +
"Otherwise, the value of the last expression is returned. " +
"Objects and arrays are JSON-serialized in the result.",
parametersSchema = ToolParametersSchema(
properties = mapOf(
"code" to ToolParameter(
type = "string",
description = "The JavaScript source code to execute"
),
"timeout_seconds" to ToolParameter(
type = "integer",
description = "Maximum execution time in seconds. Default: 30, Max: 120"
)
),
required = listOf("code")
),
requiredPermissions = emptyList(),
timeoutSeconds = MAX_TIMEOUT_SECONDS + 5
)
override suspend fun execute(parameters: Map<String, Any?>): ToolResult {
val code = parameters["code"]?.toString()
if (code.isNullOrBlank()) {
return ToolResult.error(
"validation_error",
"Parameter 'code' is required and cannot be empty"
)
}
val timeoutSeconds = parseIntParam(parameters["timeout_seconds"])
?.coerceIn(1, MAX_TIMEOUT_SECONDS)
?: DEFAULT_TIMEOUT_SECONDS
val wrappedCode = buildExecuteWrapper(code)
return jsExecutionEngine.executeFromSource(
jsSource = wrappedCode,
toolName = "js_eval",
functionName = null,
params = emptyMap(),
env = envVarStore.getAll(),
timeoutSeconds = timeoutSeconds
)
}
/**
* Build a wrapper that defines execute() for JsExecutionEngine compatibility.
*
* Strategy:
* 1. Define execute() as the entry point (required by JsExecutionEngine).
* 2. Inside execute(), eval() the user code to get the last expression value.
* 3. If the user code defines a main() function, call it instead.
* 4. This supports both "2 + 2" expressions and multi-function scripts.
*/
private fun buildExecuteWrapper(code: String): String {
val escaped = code
.replace("\\", "\\\\")
.replace("`", "\\`")
.replace("\$", "\\$")
return """
async function execute(params) {
const __result__ = eval(`$escaped`);
if (typeof main === 'function') {
return await main();
}
return __result__;
}
""".trimIndent()
}
private fun parseIntParam(value: Any?): Int? {
return when (value) {
is Int -> value
is Long -> value.toInt()
is Double -> value.toInt()
is Number -> value.toInt()
is String -> value.toIntOrNull()
else -> null
}
}
}
ToolModule Changes
// In ToolModule.kt
val toolModule = module {
// ... existing registrations ...
// RFC-034: js_eval built-in tool
single { JsEvalTool(get(), get()) }
single {
ToolRegistry().apply {
// ... existing tool registrations ...
try {
register(get<JsEvalTool>(), ToolSourceInfo.BUILTIN)
} catch (e: Exception) {
Log.e("ToolModule", "Failed to register js_eval: ${e.message}")
}
// ... rest of initialization ...
}
}
}
Imports Required for JsEvalTool
import com.oneclaw.shadow.core.model.ToolDefinition
import com.oneclaw.shadow.core.model.ToolParameter
import com.oneclaw.shadow.core.model.ToolParametersSchema
import com.oneclaw.shadow.core.model.ToolResult
import com.oneclaw.shadow.tool.engine.Tool
import com.oneclaw.shadow.tool.js.EnvironmentVariableStore
import com.oneclaw.shadow.tool.js.JsExecutionEngine
Implementation Plan
Phase 1: JsEvalTool Implementation
- Create
JsEvalTool.ktintool/builtin/ - Implement
execute()with parameter validation - Implement
buildExecuteWrapper()for code wrapping - Handle timeout clamping
Phase 2: Integration
- Update
ToolModule.ktto registerJsEvalTool - Add
JsEvalToolimport and Koin single registration - Add registration in the
ToolRegistry.applyblock
Phase 3: Testing
- Create
JsEvalToolTest.ktwith unit tests - Run Layer 1A tests (
./gradlew test) - Manual testing with various JS code snippets on device
Data Model
No data model changes. JsEvalTool implements the existing Tool interface and reuses JsExecutionEngine.
API Design
Tool Interface
Tool Name: js_eval
Parameters:
- code: string (required) -- The JavaScript source code to execute
- timeout_seconds: integer (optional, default: 30, max: 120) -- Timeout
Returns on success:
String result of the evaluation (primitives as strings, objects as JSON)
Returns on error:
ToolResult.error with descriptive message
Usage Examples
Arithmetic:
{
"name": "js_eval",
"parameters": { "code": "Math.pow(2, 32)" }
}
// Result: "4294967296"
Fibonacci:
{
"name": "js_eval",
"parameters": {
"code": "function main() {\n const fib = n => n <= 1 ? n : fib(n-1) + fib(n-2);\n return fib(20);\n}"
}
}
// Result: "6765"
Data processing:
{
"name": "js_eval",
"parameters": {
"code": "function main() {\n const nums = [10, 20, 30, 40, 50];\n return { sum: nums.reduce((a,b) => a+b), avg: nums.reduce((a,b) => a+b) / nums.length };\n}"
}
}
// Result: '{"sum":150,"avg":30}'
String processing:
{
"name": "js_eval",
"parameters": {
"code": "'Hello World'.split('').reverse().join('')"
}
}
// Result: "dlroW olleH"
Error Handling
| Error | Cause | Error Type | Handling |
|---|---|---|---|
| Empty code | Blank or null code param |
validation_error |
Return immediately with error message |
| Syntax error | Invalid JS syntax in code | execution_error |
QuickJS parse error, returned as ToolResult.error |
| Runtime error | ReferenceError, TypeError, etc. | execution_error |
QuickJS runtime error, returned as ToolResult.error |
| Timeout | Code exceeds timeout_seconds |
timeout |
Coroutine timeout cancellation, QuickJS context destroyed |
| Memory exceeded | Code allocates > 16MB | execution_error |
QuickJS throws OOM, returned as ToolResult.error |
Security Considerations
-
QuickJS Sandbox: Code runs in a sandboxed QuickJS environment with no access to Android APIs, Java classes, or the host process beyond the injected bridge functions. This is fundamentally safer than
execwhich runs shell commands. -
Memory Limits: 16MB heap and 1MB stack prevent memory exhaustion attacks.
-
Timeout: Prevents infinite loops and CPU exhaustion.
-
No Persistent State: Each
js_evalcall creates a fresh QuickJS context. No state leaks between calls. -
Bridge Access: The code has access to fs, fetch, and other bridges – same as existing JS tools. This is by design, as the AI agent already has these capabilities through other tools.
-
No eval() Escape: QuickJS
eval()is confined to the sandbox. It cannot access the Android runtime or escape the QuickJS context.
Performance
| Operation | Expected Time | Notes |
|---|---|---|
| QuickJS context creation | ~10-30ms | Lightweight engine |
| Bridge injection | ~10-20ms | 5 bridges |
| Code evaluation | Depends on code | Bounded by timeout |
| Context cleanup | < 5ms | Automatic via quickJs {} block |
Memory usage:
- QuickJS context: ~2-4MB baseline
- User code allocations: capped at 16MB
- Context is destroyed after each call – no leak
Testing Strategy
Unit Tests
JsEvalToolTest.kt:
testExecute_simpleExpression–2 + 2returns"4"testExecute_stringExpression–"hello".toUpperCase()returns"HELLO"testExecute_mainFunction– Code withmain()returns its resulttestExecute_asyncMainFunction– Code withasync main()workstestExecute_objectResult– Returned object is JSON-serializedtestExecute_arrayResult– Returned array is JSON-serializedtestExecute_nullResult–nullreturns empty stringtestExecute_emptyCode– Blank code returns validation errortestExecute_syntaxError– Invalid JS returns execution errortestExecute_runtimeError– Undefined variable returns execution errortestExecute_timeout– Infinite loop with timeout_seconds=2 triggers timeouttestExecute_timeoutClamped– timeout_seconds > 120 is clampedtestExecute_mathFunctions–Math.sqrt(144)returns"12"testExecute_jsonProcessing– JSON.parse/stringify works correctlytestDefinition– Tool definition has correct name and parameters
Manual Testing (Layer 2)
- Execute Fibonacci computation and verify correct result
- Execute data sorting/filtering and verify output
- Execute code that uses
fetch()to make an HTTP request - Execute code that uses
fs.readFile()to read a file - Execute code with intentional infinite loop and verify timeout
- Execute code that returns a complex nested JSON object
Alternatives Considered
1. Add a data Parameter for Structured Input
Approach: Add a data parameter (JSON string) that is parsed and passed to main(data).
Deferred to V2: Keeps V1 simple. The AI model can embed data directly in the code string. If needed later, easy to add as an optional parameter.
2. Use exec Tool with Node.js
Approach: Install Node.js on the device and use exec tool to run node -e "code".
Rejected: Node.js is not available on Android without a terminal emulator. QuickJS is already integrated and much lighter.
3. Extend Existing JsTool to Accept Inline Code
Approach: Add an inline_code parameter to JsTool instead of creating a new tool.
Rejected: JsTool is designed for file-based tools with a specific lifecycle. A separate JsEvalTool is cleaner and avoids complicating JsTool’s design.
Dependencies
External Dependencies
- QuickJS Android library (already a project dependency:
libs.quickjs.android)
Internal Dependencies
Toolinterface fromtool/engine/JsExecutionEnginefromtool/js/EnvironmentVariableStorefromtool/js/ToolResult,ToolDefinition,ToolParametersSchema,ToolParameterfromcore/model/
Future Extensions
- Structured input: Add
dataparameter for passing input data tomain(data) - Persistent context: Option to keep a QuickJS context alive for multi-step computations
- Console capture: Return
console.log()output alongside the result - Code templates: Pre-defined utility functions the model can import (e.g., CSV parser, statistics)
- WASM modules: Load WASM modules into QuickJS for native-speed computation
Change History
| Date | Version | Changes | Owner |
|---|---|---|---|
| 2026-03-01 | 0.1 | Initial version | - |