feat: add TypeScript execution and CDN/WAF asset classification

- Add exec_ts() and exec_ts_file() utility functions for inline and file-based TypeScript execution via Bun
- Implement CDN/WAF detection system with is_cdn, is_cloud, is_waf boolean fields for assets
- Enhance Python execution to prefer uv package manager with fallback to python3/python
- Update roadmap with cloud integration step, clarify security warning, improve documentation
This commit is contained in:
j3ssie
2026-02-15 11:32:55 +07:00
parent baac7a016a
commit 33aa3d82bc
14 changed files with 349 additions and 33 deletions
+16 -14
View File
@@ -135,19 +135,19 @@ For more information about the architecture, refer to the [Architecture Document
The high-level ambitious plan for the project, in order:
| # | Step | Status |
| :-: | ----------------------------------------------------------------------------- | :----: |
| 1 | Osmedeus Engine reforged with a next-generation architecture | ✅ |
| 2 | Flexible workflows and step types | ✅ |
| 3 | Event-driven architectural model and the different trigger event categories | ✅ |
| 4 | Beautiful UI for visualize results and workflow diagram | ✅ |
| 5 | Rewriting the workflow to adapt to new architecture and syntax | ✅ |
| 6 | Testing more utility functions like notifications | ✅ |
| 7 | SAST integration with SARIF parsing (Semgrep, Trivy, etc.) | ✅ |
| 8 | Generate diff reports showing new/removed/unchanged assets between runs. | ❌ |
| 9 | Adding step type from cloud provider that can be run via serverless | ❌ |
| N | Fancy features (to be discussed later) | ❌ |
| # | Step | Status |
| :-: | --------------------------------------------------------------------------- | :----: |
| 1 | Osmedeus Engine reforged with a next-generation architecture | ✅ |
| 2 | Flexible workflows and step types | ✅ |
| 3 | Event-driven architectural model and the different trigger event categories | ✅ |
| 4 | Beautiful UI for visualize results and workflow diagram | ✅ |
| 5 | Rewriting the workflow to adapt to new architecture and syntax | ✅ |
| 6 | Testing more utility functions like notifications | ✅ |
| 7 | SAST integration with SARIF parsing (Semgrep, Trivy, etc.) | ✅ |
| 8 | Cloud integration, which supports running the scan on the cloud provider. | ❌ |
| 9 | Generate diff reports showing new/removed/unchanged assets between runs. | ❌ |
| 10 | Adding step type from cloud provider that can be run via serverless | ❌ |
| N | Fancy features (to be discussed later) | ❌ |
## Documentation
| Topic | Link |
@@ -164,7 +164,9 @@ The high-level ambitious plan for the project, in order:
## Disclaimer
Osmedeus is designed to execute arbitrary code and commands from user supplied input via CLI, API, and workflow definitions. This flexibility is intentional and central to how the engine operates see [Security Warning](https://docs.osmedeus.org/others/security-warning) page for more details.
**Osmedeus** is designed to execute arbitrary code and commands from user supplied input via CLI, API, and workflow definitions. This flexibility is intentional and central to how the engine operates.
Please refer to the [Security Warning](https://docs.osmedeus.org/others/security-warning) page for more information on how to stay safe.
**Think twice before you:**
- Run workflows downloaded from untrusted sources
+3
View File
@@ -124,6 +124,9 @@ type Asset struct {
HostIP string `json:"host_ip,omitempty"`
TechStack []string `json:"tech_stack,omitempty"`
ContentType string `json:"content_type,omitempty"`
IsCDN bool `json:"is_cdn,omitempty"`
IsCloud bool `json:"is_cloud,omitempty"`
IsWAF bool `json:"is_waf,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+27 -1
View File
@@ -230,7 +230,7 @@ var PresetToolRegistry = map[string]PresetToolDef{
},
},
"exec_python_file": {
Description: "Run a Python file and return stdout (prefers python3)",
Description: "Run a Python file and return stdout (prefers uv, then python3)",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
@@ -242,6 +242,32 @@ var PresetToolRegistry = map[string]PresetToolDef{
"required": []string{"path"},
},
},
"exec_ts": {
Description: "Run inline TypeScript code via bun and return stdout",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"code": map[string]interface{}{
"type": "string",
"description": "TypeScript code to execute",
},
},
"required": []string{"code"},
},
},
"exec_ts_file": {
Description: "Run a TypeScript file via bun and return stdout",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{
"type": "string",
"description": "Path to the TypeScript file to execute",
},
},
"required": []string{"path"},
},
},
"run_module": {
Description: "Run an osmedeus module as a subprocess",
Parameters: map[string]interface{}{
+27
View File
@@ -282,6 +282,11 @@ func Migrate(ctx context.Context) error {
return err
}
// Add CDN/WAF classification columns to assets table if they don't exist
if err := addAssetCDNColumns(ctx); err != nil {
return err
}
return nil
}
@@ -429,6 +434,28 @@ func addAssetExternalURLColumn(ctx context.Context) error {
return nil
}
// addAssetCDNColumns adds CDN/WAF classification columns to assets table for existing databases
func addAssetCDNColumns(ctx context.Context) error {
columns := []string{
"ALTER TABLE assets ADD COLUMN is_cdn BOOLEAN DEFAULT FALSE",
"ALTER TABLE assets ADD COLUMN is_cloud BOOLEAN DEFAULT FALSE",
"ALTER TABLE assets ADD COLUMN is_waf BOOLEAN DEFAULT FALSE",
}
for _, ddl := range columns {
_, err := db.ExecContext(ctx, ddl)
if err != nil {
errStr := strings.ToLower(err.Error())
if strings.Contains(errStr, "duplicate column") ||
strings.Contains(errStr, "already exists") ||
strings.Contains(errStr, "sqlstate 42701") {
continue
}
return fmt.Errorf("failed to add CDN column: %w", err)
}
}
return nil
}
// createAssetIndexes creates indexes for the assets table
func createAssetIndexes(ctx context.Context) error {
indexes := []string{
+56
View File
@@ -151,6 +151,9 @@ func (i *JSONLImporter) insertAssetBatch(ctx context.Context, assets []*Asset) (
Set("blob_content = CASE WHEN EXCLUDED.blob_content != '' THEN EXCLUDED.blob_content ELSE assets.blob_content END").
Set("raw_data = CASE WHEN EXCLUDED.raw_data != '' THEN EXCLUDED.raw_data ELSE assets.raw_data END").
Set("asset_type = CASE WHEN EXCLUDED.asset_type != '' THEN EXCLUDED.asset_type ELSE assets.asset_type END").
Set("is_cdn = CASE WHEN EXCLUDED.is_cdn OR assets.is_cdn THEN TRUE ELSE FALSE END").
Set("is_cloud = CASE WHEN EXCLUDED.is_cloud OR assets.is_cloud THEN TRUE ELSE FALSE END").
Set("is_waf = CASE WHEN EXCLUDED.is_waf OR assets.is_waf THEN TRUE ELSE FALSE END").
Set("updated_at = EXCLUDED.updated_at").
Exec(ctx)
@@ -162,6 +165,56 @@ func (i *JSONLImporter) insertAssetBatch(ctx context.Context, assets []*Asset) (
return int(rowsAffected), nil
}
// cloudProviderPatterns are substrings used to detect cloud-hosted CDNs.
var cloudProviderPatterns = []string{
"aws", "amazon", "cloudfront",
"google", "gcp", "cloud cdn",
"azure", "microsoft",
"akamai", "fastly",
"oracle",
"alibaba", "aliyun",
"tencent",
"digitalocean",
}
// DetectCDNFlags derives is_cdn, is_cloud, and is_waf booleans from raw httpx JSON data.
//
// Rules:
// - is_cdn = "cdn" is true OR "cdn_name" is non-empty
// - is_cloud = cdn_name matches a cloud provider pattern
// - is_waf = "cdn_type" equals "waf"
func DetectCDNFlags(raw map[string]interface{}) (isCDN, isCloud, isWAF bool) {
// Check "cdn" boolean field
if v, ok := raw["cdn"].(bool); ok && v {
isCDN = true
}
// Check "cdn_name" string field
cdnName := ""
if v, ok := raw["cdn_name"].(string); ok && v != "" {
cdnName = v
isCDN = true
}
// Cloud detection from cdn_name
if cdnName != "" {
lower := strings.ToLower(cdnName)
for _, pattern := range cloudProviderPatterns {
if strings.Contains(lower, pattern) {
isCloud = true
break
}
}
}
// WAF detection from cdn_type
if v, ok := raw["cdn_type"].(string); ok && strings.EqualFold(v, "waf") {
isWAF = true
}
return
}
// ParseAssetLine parses a single JSONL line into an Asset
func ParseAssetLine(line []byte, defaultWorkspace, source string) (*Asset, error) {
var raw map[string]interface{}
@@ -267,6 +320,9 @@ func ParseAssetLine(line []byte, defaultWorkspace, source string) (*Asset, error
asset.AssetType = v
}
// CDN/WAF classification
asset.IsCDN, asset.IsCloud, asset.IsWAF = DetectCDNFlags(raw)
// Validate required fields
if asset.AssetValue == "" {
return nil, fmt.Errorf("asset_value is required")
+5
View File
@@ -235,6 +235,11 @@ type Asset struct {
RawResponse string `bun:"raw_response" json:"raw_response,omitempty"`
ScreenshotBase64Data string `bun:"screenshot_base64_data" json:"screenshot_base64_data,omitempty"`
// CDN/WAF classification
IsCDN bool `bun:"is_cdn" json:"is_cdn,omitempty"`
IsCloud bool `bun:"is_cloud" json:"is_cloud,omitempty"`
IsWAF bool `bun:"is_waf" json:"is_waf,omitempty"`
// for repository/file assets
Language string `bun:"language" json:"language,omitempty"`
Size int64 `bun:"size" json:"size,omitempty"`
@@ -299,6 +299,9 @@ func (r *AssetRepository) Upsert(ctx context.Context, asset *database.Asset) err
Set("loc = CASE WHEN EXCLUDED.loc != 0 THEN EXCLUDED.loc ELSE assets.loc END").
Set("blob_content = CASE WHEN EXCLUDED.blob_content != '' THEN EXCLUDED.blob_content ELSE assets.blob_content END").
Set("raw_data = CASE WHEN EXCLUDED.raw_data != '' THEN EXCLUDED.raw_data ELSE assets.raw_data END").
Set("is_cdn = CASE WHEN EXCLUDED.is_cdn OR assets.is_cdn THEN TRUE ELSE FALSE END").
Set("is_cloud = CASE WHEN EXCLUDED.is_cloud OR assets.is_cloud THEN TRUE ELSE FALSE END").
Set("is_waf = CASE WHEN EXCLUDED.is_waf OR assets.is_waf THEN TRUE ELSE FALSE END").
Set("updated_at = EXCLUDED.updated_at").
Exec(ctx)
return err
@@ -330,6 +333,9 @@ func (r *AssetRepository) BulkUpsert(ctx context.Context, assets []*database.Ass
Set("loc = CASE WHEN EXCLUDED.loc != 0 THEN EXCLUDED.loc ELSE assets.loc END").
Set("blob_content = CASE WHEN EXCLUDED.blob_content != '' THEN EXCLUDED.blob_content ELSE assets.blob_content END").
Set("raw_data = CASE WHEN EXCLUDED.raw_data != '' THEN EXCLUDED.raw_data ELSE assets.raw_data END").
Set("is_cdn = CASE WHEN EXCLUDED.is_cdn OR assets.is_cdn THEN TRUE ELSE FALSE END").
Set("is_cloud = CASE WHEN EXCLUDED.is_cloud OR assets.is_cloud THEN TRUE ELSE FALSE END").
Set("is_waf = CASE WHEN EXCLUDED.is_waf OR assets.is_waf THEN TRUE ELSE FALSE END").
Set("updated_at = EXCLUDED.updated_at").
Exec(ctx)
return err
+4
View File
@@ -973,6 +973,10 @@ func buildPresetCallExpr(funcName string, args map[string]interface{}) string {
return fmt.Sprintf("exec_python(%s)", jsQuote(getStringArg(args, "code")))
case "exec_python_file":
return fmt.Sprintf("exec_python_file(%s)", jsQuote(getStringArg(args, "path")))
case "exec_ts":
return fmt.Sprintf("exec_ts(%s)", jsQuote(getStringArg(args, "code")))
case "exec_ts_file":
return fmt.Sprintf("exec_ts_file(%s)", jsQuote(getStringArg(args, "path")))
case "run_module":
return fmt.Sprintf("run_module(%s, %s, %s)", jsQuote(getStringArg(args, "module")), jsQuote(getStringArg(args, "target")), jsQuote(getStringArg(args, "params")))
case "run_flow":
+10 -4
View File
@@ -113,8 +113,10 @@ const (
FnRunFlow = "run_flow" // run_flow(flow, target, params?) -> string (run osmedeus flow)
FnRunOnMaster = "run_on_master" // run_on_master(action, ...args) -> bool (execute on master node)
FnRunOnWorker = "run_on_worker" // run_on_worker(scope, action, ...args) -> bool (execute on worker nodes)
FnExecPython = "exec_python" // exec_python(code) -> string (run inline Python, prefer python3)
FnExecPythonFile = "exec_python_file" // exec_python_file(path) -> string (run Python file, prefer python3)
FnExecPython = "exec_python" // exec_python(code) -> string (run inline Python, prefer uv → python3 → python)
FnExecPythonFile = "exec_python_file" // exec_python_file(path) -> string (run Python file, prefer uv → python3 → python)
FnExecTS = "exec_ts" // exec_ts(code) -> string (run inline TypeScript via bun)
FnExecTSFile = "exec_ts_file" // exec_ts_file(path) -> string (run TypeScript file via bun)
)
// Logging Functions - Log messages with level prefixes
@@ -439,6 +441,8 @@ func AllFunctions() []string {
FnRunOnWorker,
FnExecPython,
FnExecPythonFile,
FnExecTS,
FnExecTSFile,
// Logging Functions
FnLogDebug,
@@ -785,8 +789,10 @@ func FunctionRegistry() map[string][]FunctionInfo {
{FnPickValid, "pick_valid(v1, v2, ..., v10)", "Return first valid value from up to 10 arguments", "any", "pick_valid('', '', 'hello', 'world')"},
{FnRunModule, "run_module(module, target, params?)", "Run osmedeus module as subprocess, optional comma-separated key=value params", "string", "run_module('subdomain', 'example.com', 'threads=10,deep=true')"},
{FnRunFlow, "run_flow(flow, target, params?)", "Run osmedeus flow as subprocess, optional comma-separated key=value params", "string", "run_flow('general', 'example.com')"},
{FnExecPython, "exec_python(code)", "Run inline Python code via python3 -c (falls back to python)", "string", "exec_python('print(2+2)')"},
{FnExecPythonFile, "exec_python_file(path)", "Run a Python file via python3 (falls back to python)", "string", "exec_python_file('/tmp/script.py')"},
{FnExecPython, "exec_python(code)", "Run inline Python code (prefers uv, falls back to python3/python)", "string", "exec_python('print(2+2)')"},
{FnExecPythonFile, "exec_python_file(path)", "Run a Python file (prefers uv, falls back to python3/python)", "string", "exec_python_file('/tmp/script.py')"},
{FnExecTS, "exec_ts(code)", "Run inline TypeScript code via bun -e", "string", "exec_ts('console.log(2+2)')"},
{FnExecTSFile, "exec_ts_file(path)", "Run a TypeScript file via bun run", "string", "exec_ts_file('/tmp/script.ts')"},
},
CategoryLogging: {
{FnLogDebug, "log_debug(message)", "Log debug message with [DEBUG] prefix", "void", "log_debug('Processing target')"},
+16
View File
@@ -218,6 +218,11 @@ func unmarshalAssetJSON(rawJSON []byte, asset *database.Asset) error {
}
}
}
// CDN/WAF classification (OR into existing values)
isCDN, isCloud, isWAF := database.DetectCDNFlags(raw)
asset.IsCDN = asset.IsCDN || isCDN
asset.IsCloud = asset.IsCloud || isCloud
asset.IsWAF = asset.IsWAF || isWAF
}
return nil
}
@@ -2139,6 +2144,9 @@ func hasAssetChanged(existing, new *database.Asset) bool {
existing.TLS != new.TLS ||
existing.Words != new.Words ||
existing.Lines != new.Lines ||
existing.IsCDN != new.IsCDN ||
existing.IsCloud != new.IsCloud ||
existing.IsWAF != new.IsWAF ||
!slicesEqual(existing.Technologies, new.Technologies)
}
@@ -2260,6 +2268,9 @@ func mapJSONToAsset(data map[string]interface{}, workspace, rawLine string) data
asset.Remarks = append(asset.Remarks, v)
}
// CDN/WAF classification
asset.IsCDN, asset.IsCloud, asset.IsWAF = database.DetectCDNFlags(data)
// Prefer URL over raw IP for asset_value (more descriptive identifier)
if asset.AssetValue != "" && asset.URL != "" && net.ParseIP(asset.AssetValue) != nil {
asset.AssetValue = asset.URL
@@ -2497,6 +2508,11 @@ func (vf *vmFunc) dbImportCustomAsset(call goja.FunctionCall) goja.Value {
if ws, ok := rawData["webserver"].(string); ok && ws != "" {
asset.Remarks = append(asset.Remarks, ws)
}
// CDN/WAF classification (OR into existing values)
isCDN, isCloud, isWAF := database.DetectCDNFlags(rawData)
asset.IsCDN = asset.IsCDN || isCDN
asset.IsCloud = asset.IsCloud || isCloud
asset.IsWAF = asset.IsWAF || isWAF
}
// Apply optional defaults when the JSONL line has no value
+10
View File
@@ -26,6 +26,11 @@ func mergeInt64(existing, incoming int64) int64 {
return existing
}
// mergeBool returns true if either value is true (once true, stays true).
func mergeBool(existing, incoming bool) bool {
return existing || incoming
}
// mergeStringSlice returns incoming if non-nil and non-empty, otherwise existing.
func mergeStringSlice(existing, incoming []string) []string {
if len(incoming) > 0 {
@@ -74,6 +79,11 @@ func mergeAssetFields(existing, incoming *database.Asset) {
incoming.RawResponse = mergeString(existing.RawResponse, incoming.RawResponse)
incoming.ScreenshotBase64Data = mergeString(existing.ScreenshotBase64Data, incoming.ScreenshotBase64Data)
// CDN/WAF classification
incoming.IsCDN = mergeBool(existing.IsCDN, incoming.IsCDN)
incoming.IsCloud = mergeBool(existing.IsCloud, incoming.IsCloud)
incoming.IsWAF = mergeBool(existing.IsWAF, incoming.IsWAF)
// Repository/file assets
incoming.Language = mergeString(existing.Language, incoming.Language)
incoming.Size = mergeInt64(existing.Size, incoming.Size)
+2
View File
@@ -124,6 +124,8 @@ func (r *GojaRuntime) registerFunctionsOnVM(vm *goja.Runtime) {
_ = vm.Set(FnRunOnWorker, vf.runOnWorker)
_ = vm.Set(FnExecPython, vf.execPython)
_ = vm.Set(FnExecPythonFile, vf.execPythonFile)
_ = vm.Set(FnExecTS, vf.execTypeScript)
_ = vm.Set(FnExecTSFile, vf.execTypeScriptFile)
// Logging functions
_ = vm.Set(FnLogDebug, vf.logDebug)
+102 -14
View File
@@ -748,15 +748,24 @@ func (vf *vmFunc) bash(call goja.FunctionCall) goja.Value {
return vf.execCmd(call)
}
// findPythonBin returns "python3" if available, otherwise "python".
func findPythonBin() string {
if _, err := exec.LookPath("python3"); err == nil {
return "python3"
}
return "python"
// pythonRunner holds the detected Python binary and whether it's uv.
type pythonRunner struct {
bin string // "uv", "python3", or "python"
isUV bool
}
// execPython runs inline Python code via `python3 -c '<code>'`.
// findPythonRunner returns the preferred Python runner: uv → python3 → python.
func findPythonRunner() pythonRunner {
if _, err := exec.LookPath("uv"); err == nil {
return pythonRunner{bin: "uv", isUV: true}
}
if _, err := exec.LookPath("python3"); err == nil {
return pythonRunner{bin: "python3"}
}
return pythonRunner{bin: "python"}
}
// execPython runs inline Python code. Prefers `uv run -` (stdin), falls back to `python3 -c`.
// Usage: exec_python(code) -> string
func (vf *vmFunc) execPython(call goja.FunctionCall) goja.Value {
code := call.Argument(0).String()
@@ -767,13 +776,19 @@ func (vf *vmFunc) execPython(call goja.FunctionCall) goja.Value {
return vf.vm.ToValue("")
}
pythonBin := findPythonBin()
runner := findPythonRunner()
// @NOTE: This is intentional - exec_python() is a utility function exposed to workflow
// definitions for executing Python code. Input comes from trusted workflow YAML files.
cmd := exec.Command(pythonBin, "-c", code)
var cmd *exec.Cmd
if runner.isUV {
cmd = exec.Command("uv", "run", "-")
cmd.Stdin = strings.NewReader(code)
} else {
cmd = exec.Command(runner.bin, "-c", code)
}
output, err := cmd.Output()
if err != nil {
logger.Get().Warn("execPython: command failed", zap.String("python", pythonBin), zap.Error(err))
logger.Get().Warn("execPython: command failed", zap.String("runner", runner.bin), zap.Error(err))
return vf.vm.ToValue("")
}
@@ -781,7 +796,7 @@ func (vf *vmFunc) execPython(call goja.FunctionCall) goja.Value {
return vf.vm.ToValue(strings.TrimSpace(string(output)))
}
// execPythonFile runs a Python file via `python3 <path>`.
// execPythonFile runs a Python file. Prefers `uv run <path>`, falls back to `python3 <path>`.
// Usage: exec_python_file(path) -> string
func (vf *vmFunc) execPythonFile(call goja.FunctionCall) goja.Value {
path := call.Argument(0).String()
@@ -792,13 +807,18 @@ func (vf *vmFunc) execPythonFile(call goja.FunctionCall) goja.Value {
return vf.vm.ToValue("")
}
pythonBin := findPythonBin()
runner := findPythonRunner()
// @NOTE: This is intentional - exec_python_file() is a utility function exposed to workflow
// definitions for executing Python files. Input comes from trusted workflow YAML files.
cmd := exec.Command(pythonBin, path)
var cmd *exec.Cmd
if runner.isUV {
cmd = exec.Command("uv", "run", path)
} else {
cmd = exec.Command(runner.bin, path)
}
output, err := cmd.Output()
if err != nil {
logger.Get().Warn("execPythonFile: command failed", zap.String("python", pythonBin), zap.String("path", path), zap.Error(err))
logger.Get().Warn("execPythonFile: command failed", zap.String("runner", runner.bin), zap.String("path", path), zap.Error(err))
return vf.vm.ToValue("")
}
@@ -806,6 +826,74 @@ func (vf *vmFunc) execPythonFile(call goja.FunctionCall) goja.Value {
return vf.vm.ToValue(strings.TrimSpace(string(output)))
}
// findBunBin returns "bun" if available in PATH, empty string otherwise.
func findBunBin() string {
if _, err := exec.LookPath("bun"); err == nil {
return "bun"
}
return ""
}
// execTypeScript runs inline TypeScript code via `bun -e '<code>'`.
// Usage: exec_ts(code) -> string
func (vf *vmFunc) execTypeScript(call goja.FunctionCall) goja.Value {
code := call.Argument(0).String()
logger.Get().Debug("Calling "+terminal.HiGreen("execTypeScript"), zap.Int("codeLength", len(code)))
if code == "undefined" || code == "" {
logger.Get().Warn("execTypeScript: empty code provided")
return vf.vm.ToValue("")
}
bunBin := findBunBin()
if bunBin == "" {
logger.Get().Warn("execTypeScript: bun not found in PATH")
return vf.vm.ToValue("")
}
// @NOTE: This is intentional - exec_ts() is a utility function exposed to workflow
// definitions for executing TypeScript code. Input comes from trusted workflow YAML files.
cmd := exec.Command(bunBin, "-e", code)
output, err := cmd.Output()
if err != nil {
logger.Get().Warn("execTypeScript: command failed", zap.Error(err))
return vf.vm.ToValue("")
}
logger.Get().Debug(terminal.HiGreen("execTypeScript")+" result", zap.Int("outputLength", len(output)))
return vf.vm.ToValue(strings.TrimSpace(string(output)))
}
// execTypeScriptFile runs a TypeScript file via `bun run <path>`.
// Usage: exec_ts_file(path) -> string
func (vf *vmFunc) execTypeScriptFile(call goja.FunctionCall) goja.Value {
path := call.Argument(0).String()
logger.Get().Debug("Calling "+terminal.HiGreen("execTypeScriptFile"), zap.String("path", path))
if path == "undefined" || path == "" {
logger.Get().Warn("execTypeScriptFile: empty path provided")
return vf.vm.ToValue("")
}
bunBin := findBunBin()
if bunBin == "" {
logger.Get().Warn("execTypeScriptFile: bun not found in PATH")
return vf.vm.ToValue("")
}
// @NOTE: This is intentional - exec_ts_file() is a utility function exposed to workflow
// definitions for executing TypeScript files. Input comes from trusted workflow YAML files.
cmd := exec.Command(bunBin, "run", path)
output, err := cmd.Output()
if err != nil {
logger.Get().Warn("execTypeScriptFile: command failed", zap.String("path", path), zap.Error(err))
return vf.vm.ToValue("")
}
logger.Get().Debug(terminal.HiGreen("execTypeScriptFile")+" result", zap.String("path", path), zap.Int("outputLength", len(output)))
return vf.vm.ToValue(strings.TrimSpace(string(output)))
}
// commandExists checks if a command is available in PATH
// Usage: commandExists(command) -> bool
func (vf *vmFunc) commandExists(call goja.FunctionCall) goja.Value {
+65
View File
@@ -791,6 +791,71 @@ func TestExecPythonFile(t *testing.T) {
})
}
func TestExecTypeScript(t *testing.T) {
bunBin := findBunBin()
if bunBin == "" {
t.Skip("bun not found in PATH, skipping TypeScript tests")
}
runtime := NewOttoRuntime()
t.Run("simple console.log", func(t *testing.T) {
result, err := runtime.Execute(`exec_ts("console.log('hello')")`, nil)
require.NoError(t, err)
assert.Equal(t, "hello", result)
})
t.Run("multiline code", func(t *testing.T) {
result, err := runtime.Execute(`exec_ts("const x = 2 + 3;\nconsole.log(x)")`, nil)
require.NoError(t, err)
assert.Equal(t, "5", result)
})
t.Run("empty code returns empty string", func(t *testing.T) {
result, err := runtime.Execute(`exec_ts("")`, nil)
require.NoError(t, err)
assert.Equal(t, "", result)
})
t.Run("invalid code returns empty string", func(t *testing.T) {
result, err := runtime.Execute(`exec_ts("process.exit(1)")`, nil)
require.NoError(t, err)
assert.Equal(t, "", result)
})
}
func TestExecTypeScriptFile(t *testing.T) {
bunBin := findBunBin()
if bunBin == "" {
t.Skip("bun not found in PATH, skipping TypeScript file tests")
}
runtime := NewOttoRuntime()
t.Run("run temp ts file", func(t *testing.T) {
tmpDir := t.TempDir()
tsFile := filepath.Join(tmpDir, "test.ts")
err := os.WriteFile(tsFile, []byte("console.log('from file')"), 0644)
require.NoError(t, err)
result, err := runtime.Execute(`exec_ts_file("`+tsFile+`")`, nil)
require.NoError(t, err)
assert.Equal(t, "from file", result)
})
t.Run("empty path returns empty string", func(t *testing.T) {
result, err := runtime.Execute(`exec_ts_file("")`, nil)
require.NoError(t, err)
assert.Equal(t, "", result)
})
t.Run("nonexistent file returns empty string", func(t *testing.T) {
result, err := runtime.Execute(`exec_ts_file("/tmp/nonexistent_ts_file_12345.ts")`, nil)
require.NoError(t, err)
assert.Equal(t, "", result)
})
}
func TestMoveFile(t *testing.T) {
runtime := NewOttoRuntime()