mirror of
https://github.com/j3ssie/osmedeus.git
synced 2026-08-25 17:12:30 +02:00
- Implement agent-acp step type for spawning external ACP agent subprocesses via Agent Communication Protocol - Add ACPExecutor with validation, field rendering, and subprocess lifecycle management - Integrate agent-acp field rendering in StepDispatcher (batch and sequential modes) - Add run_agent() utility function for workflows to execute ACP agents from steps and JS context - Add osmedeus agent CLI command for interactive agent execution with --agent, --cwd, --timeout, --stdin, and --list flags - Add /osm/api/agent/chat/completions REST endpoint with OpenAI-compatible chat format and concurrency control - Support agent selection via: built-in names (claude-code, codex, opencode, gemini) or custom acp_config.command - Add step-level configuration: cwd, allowed_paths, acp_config (command, args, env, write_enabled) - Add comprehensive E2E tests for agent-acp workflows (basic, minimal, config, codex variants) - Add test workflows in test/testdata/workflows/agent-and-llm/ - Update AGENTS.md documentation with agent-acp examples, CLI usage, and API endpoints
1635 lines
53 KiB
Go
1635 lines
53 KiB
Go
package functions
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"io"
|
|
mrand "math/rand"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/dop251/goja"
|
|
"github.com/google/uuid"
|
|
"github.com/j3ssie/osmedeus/v5/internal/core"
|
|
"github.com/j3ssie/osmedeus/v5/internal/logger"
|
|
"github.com/j3ssie/osmedeus/v5/internal/retry"
|
|
"github.com/j3ssie/osmedeus/v5/internal/terminal"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// httpRequest makes an HTTP request with custom method, headers, and body
|
|
// Usage: httpRequest(url, method, headers, body)
|
|
// headers is an object like {"Content-Type": "application/json", "Authorization": "Bearer token"}
|
|
// Returns: {statusCode: int, body: string, headers: object, error: string}
|
|
func (vf *vmFunc) httpRequest(call goja.FunctionCall) goja.Value {
|
|
urlStr := call.Argument(0).String()
|
|
method := call.Argument(1).String()
|
|
logger.Get().Debug("Calling "+terminal.HiGreen("httpRequest"), zap.String("url", urlStr), zap.String("method", method))
|
|
|
|
if urlStr == "undefined" || urlStr == "" {
|
|
logger.Get().Warn("httpRequest: url is required")
|
|
return vf.vm.ToValue(map[string]interface{}{
|
|
"statusCode": 0,
|
|
"body": "",
|
|
"headers": map[string]string{},
|
|
"error": "url is required",
|
|
})
|
|
}
|
|
|
|
if method == "undefined" || method == "" {
|
|
method = "GET"
|
|
}
|
|
|
|
// Parse headers
|
|
var headers map[string]interface{}
|
|
if !goja.IsUndefined(call.Argument(2)) {
|
|
exported := call.Argument(2).Export()
|
|
if h, ok := exported.(map[string]interface{}); ok {
|
|
headers = h
|
|
}
|
|
}
|
|
|
|
// Parse body
|
|
var body io.Reader
|
|
bodyStr := call.Argument(3).String()
|
|
if bodyStr != "undefined" && bodyStr != "" {
|
|
body = bytes.NewBufferString(bodyStr)
|
|
}
|
|
|
|
// Create request
|
|
req, err := http.NewRequest(method, urlStr, body)
|
|
if err != nil {
|
|
logger.Get().Warn("httpRequest: failed to create request", zap.String("url", urlStr), zap.Error(err))
|
|
return vf.vm.ToValue(map[string]interface{}{
|
|
"statusCode": 0,
|
|
"body": "",
|
|
"headers": map[string]string{},
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
// Set default User-Agent if not provided
|
|
if headers == nil {
|
|
headers = make(map[string]interface{})
|
|
}
|
|
if _, hasUA := headers["User-Agent"]; !hasUA {
|
|
headers["User-Agent"] = core.DefaultUA
|
|
}
|
|
|
|
// Set headers
|
|
for k, v := range headers {
|
|
if str, ok := v.(string); ok {
|
|
req.Header.Set(k, str)
|
|
}
|
|
}
|
|
|
|
// Create client with timeout and TLS skip verify
|
|
client := &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
Transport: &http.Transport{
|
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
|
},
|
|
}
|
|
|
|
// Execute request with retry logic (3 attempts, exponential backoff)
|
|
var resp *http.Response
|
|
ctx := context.Background()
|
|
retryCfg := retry.Config{
|
|
MaxAttempts: 3,
|
|
InitialDelay: 200 * time.Millisecond,
|
|
MaxDelay: 2 * time.Second,
|
|
Multiplier: 2.0,
|
|
}
|
|
|
|
err = retry.Do(ctx, retryCfg, func() error {
|
|
var reqErr error
|
|
// Need to recreate body for retries since it may have been consumed
|
|
if bodyStr != "undefined" && bodyStr != "" {
|
|
req.Body = io.NopCloser(bytes.NewBufferString(bodyStr))
|
|
}
|
|
resp, reqErr = client.Do(req)
|
|
if reqErr != nil {
|
|
return retry.Retryable(reqErr)
|
|
}
|
|
// Retry on server errors (5xx)
|
|
if resp.StatusCode >= 500 {
|
|
_ = resp.Body.Close()
|
|
return retry.Retryable(fmt.Errorf("server error: %d", resp.StatusCode))
|
|
}
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
logger.Get().Warn("httpRequest: request failed after retries", zap.String("url", urlStr), zap.Error(err))
|
|
return vf.vm.ToValue(map[string]interface{}{
|
|
"statusCode": 0,
|
|
"body": "",
|
|
"headers": map[string]string{},
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
// Read response body
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
logger.Get().Warn("httpRequest: failed to read response body", zap.String("url", urlStr), zap.Error(err))
|
|
return vf.vm.ToValue(map[string]interface{}{
|
|
"statusCode": resp.StatusCode,
|
|
"body": "",
|
|
"headers": map[string]string{},
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
// Extract response headers
|
|
respHeaders := make(map[string]string)
|
|
for k, v := range resp.Header {
|
|
if len(v) > 0 {
|
|
respHeaders[k] = v[0]
|
|
}
|
|
}
|
|
|
|
logger.Get().Debug(terminal.HiGreen("httpRequest")+" result", zap.String("url", urlStr), zap.Int("statusCode", resp.StatusCode), zap.Int("bodySize", len(respBody)))
|
|
return vf.vm.ToValue(map[string]interface{}{
|
|
"statusCode": resp.StatusCode,
|
|
"body": string(respBody),
|
|
"headers": respHeaders,
|
|
"error": "",
|
|
})
|
|
}
|
|
|
|
// httpGet makes a GET request to the specified URL
|
|
// Usage: http_get(url)
|
|
// Returns: {status_code, error, message, response_headers, response_body, content_length, response_time_ms}
|
|
func (vf *vmFunc) httpGet(call goja.FunctionCall) goja.Value {
|
|
start := time.Now()
|
|
urlStr := call.Argument(0).String()
|
|
logger.Get().Debug("Calling "+terminal.HiGreen("http_get"), zap.String("url", urlStr))
|
|
|
|
if urlStr == "undefined" || urlStr == "" {
|
|
logger.Get().Warn("http_get: url is required")
|
|
return vf.vm.ToValue(map[string]interface{}{
|
|
"status_code": 0,
|
|
"error": "url is required",
|
|
"message": "error",
|
|
"response_headers": map[string]string{},
|
|
"response_body": "",
|
|
"content_length": 0,
|
|
"response_time_ms": time.Since(start).Milliseconds(),
|
|
})
|
|
}
|
|
|
|
// Create request
|
|
req, err := http.NewRequest("GET", urlStr, nil)
|
|
if err != nil {
|
|
logger.Get().Warn("http_get: failed to create request", zap.String("url", urlStr), zap.Error(err))
|
|
return vf.vm.ToValue(map[string]interface{}{
|
|
"status_code": 0,
|
|
"error": err.Error(),
|
|
"message": "error",
|
|
"response_headers": map[string]string{},
|
|
"response_body": "",
|
|
"content_length": 0,
|
|
"response_time_ms": time.Since(start).Milliseconds(),
|
|
})
|
|
}
|
|
|
|
// Set default User-Agent
|
|
req.Header.Set("User-Agent", core.DefaultUA)
|
|
|
|
// Create client with timeout and TLS skip verify
|
|
client := &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
Transport: &http.Transport{
|
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
|
},
|
|
}
|
|
|
|
// Execute request with retry logic (3 attempts, exponential backoff)
|
|
var resp *http.Response
|
|
ctx := context.Background()
|
|
retryCfg := retry.Config{
|
|
MaxAttempts: 3,
|
|
InitialDelay: 200 * time.Millisecond,
|
|
MaxDelay: 2 * time.Second,
|
|
Multiplier: 2.0,
|
|
}
|
|
|
|
err = retry.Do(ctx, retryCfg, func() error {
|
|
var reqErr error
|
|
resp, reqErr = client.Do(req)
|
|
if reqErr != nil {
|
|
return retry.Retryable(reqErr)
|
|
}
|
|
// Retry on server errors (5xx)
|
|
if resp.StatusCode >= 500 {
|
|
_ = resp.Body.Close()
|
|
return retry.Retryable(fmt.Errorf("server error: %d", resp.StatusCode))
|
|
}
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
logger.Get().Warn("http_get: request failed after retries", zap.String("url", urlStr), zap.Error(err))
|
|
return vf.vm.ToValue(map[string]interface{}{
|
|
"status_code": 0,
|
|
"error": err.Error(),
|
|
"message": "error",
|
|
"response_headers": map[string]string{},
|
|
"response_body": "",
|
|
"content_length": 0,
|
|
"response_time_ms": time.Since(start).Milliseconds(),
|
|
})
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
// Read response body
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
logger.Get().Warn("http_get: failed to read response body", zap.String("url", urlStr), zap.Error(err))
|
|
return vf.vm.ToValue(map[string]interface{}{
|
|
"status_code": resp.StatusCode,
|
|
"error": err.Error(),
|
|
"message": "error",
|
|
"response_headers": map[string]string{},
|
|
"response_body": "",
|
|
"content_length": 0,
|
|
"response_time_ms": time.Since(start).Milliseconds(),
|
|
})
|
|
}
|
|
|
|
// Extract response headers
|
|
respHeaders := make(map[string]string)
|
|
for k, v := range resp.Header {
|
|
if len(v) > 0 {
|
|
respHeaders[k] = v[0]
|
|
}
|
|
}
|
|
|
|
elapsed := time.Since(start).Milliseconds()
|
|
logger.Get().Debug(terminal.HiGreen("http_get")+" result", zap.String("url", urlStr), zap.Int("status_code", resp.StatusCode), zap.Int("bodySize", len(respBody)), zap.Int64("response_time_ms", elapsed))
|
|
return vf.vm.ToValue(map[string]interface{}{
|
|
"status_code": resp.StatusCode,
|
|
"error": nil,
|
|
"message": "success",
|
|
"response_headers": respHeaders,
|
|
"response_body": string(respBody),
|
|
"content_length": len(respBody),
|
|
"response_time_ms": elapsed,
|
|
})
|
|
}
|
|
|
|
// httpPost makes a POST request to the specified URL with the given body
|
|
// Usage: http_post(url, body)
|
|
// Returns: {status_code, error, message, response_headers, response_body, content_length, response_time_ms}
|
|
func (vf *vmFunc) httpPost(call goja.FunctionCall) goja.Value {
|
|
start := time.Now()
|
|
urlStr := call.Argument(0).String()
|
|
bodyStr := call.Argument(1).String()
|
|
logger.Get().Debug("Calling "+terminal.HiGreen("http_post"), zap.String("url", urlStr), zap.Int("bodyLength", len(bodyStr)))
|
|
|
|
if urlStr == "undefined" || urlStr == "" {
|
|
logger.Get().Warn("http_post: url is required")
|
|
return vf.vm.ToValue(map[string]interface{}{
|
|
"status_code": 0,
|
|
"error": "url is required",
|
|
"message": "error",
|
|
"response_headers": map[string]string{},
|
|
"response_body": "",
|
|
"content_length": 0,
|
|
"response_time_ms": time.Since(start).Milliseconds(),
|
|
})
|
|
}
|
|
|
|
// Handle undefined body as empty string
|
|
var body io.Reader
|
|
if bodyStr != "undefined" && bodyStr != "" {
|
|
body = bytes.NewBufferString(bodyStr)
|
|
}
|
|
|
|
// Create request
|
|
req, err := http.NewRequest("POST", urlStr, body)
|
|
if err != nil {
|
|
logger.Get().Warn("http_post: failed to create request", zap.String("url", urlStr), zap.Error(err))
|
|
return vf.vm.ToValue(map[string]interface{}{
|
|
"status_code": 0,
|
|
"error": err.Error(),
|
|
"message": "error",
|
|
"response_headers": map[string]string{},
|
|
"response_body": "",
|
|
"content_length": 0,
|
|
"response_time_ms": time.Since(start).Milliseconds(),
|
|
})
|
|
}
|
|
|
|
// Set default User-Agent and Content-Type
|
|
req.Header.Set("User-Agent", core.DefaultUA)
|
|
if bodyStr != "undefined" && bodyStr != "" {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
|
|
// Create client with timeout and TLS skip verify
|
|
client := &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
Transport: &http.Transport{
|
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
|
},
|
|
}
|
|
|
|
// Execute request with retry logic (3 attempts, exponential backoff)
|
|
var resp *http.Response
|
|
ctx := context.Background()
|
|
retryCfg := retry.Config{
|
|
MaxAttempts: 3,
|
|
InitialDelay: 200 * time.Millisecond,
|
|
MaxDelay: 2 * time.Second,
|
|
Multiplier: 2.0,
|
|
}
|
|
|
|
err = retry.Do(ctx, retryCfg, func() error {
|
|
var reqErr error
|
|
// Need to recreate body for retries since it may have been consumed
|
|
if bodyStr != "undefined" && bodyStr != "" {
|
|
req.Body = io.NopCloser(bytes.NewBufferString(bodyStr))
|
|
}
|
|
resp, reqErr = client.Do(req)
|
|
if reqErr != nil {
|
|
return retry.Retryable(reqErr)
|
|
}
|
|
// Retry on server errors (5xx)
|
|
if resp.StatusCode >= 500 {
|
|
_ = resp.Body.Close()
|
|
return retry.Retryable(fmt.Errorf("server error: %d", resp.StatusCode))
|
|
}
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
logger.Get().Warn("http_post: request failed after retries", zap.String("url", urlStr), zap.Error(err))
|
|
return vf.vm.ToValue(map[string]interface{}{
|
|
"status_code": 0,
|
|
"error": err.Error(),
|
|
"message": "error",
|
|
"response_headers": map[string]string{},
|
|
"response_body": "",
|
|
"content_length": 0,
|
|
"response_time_ms": time.Since(start).Milliseconds(),
|
|
})
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
// Read response body
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
logger.Get().Warn("http_post: failed to read response body", zap.String("url", urlStr), zap.Error(err))
|
|
return vf.vm.ToValue(map[string]interface{}{
|
|
"status_code": resp.StatusCode,
|
|
"error": err.Error(),
|
|
"message": "error",
|
|
"response_headers": map[string]string{},
|
|
"response_body": "",
|
|
"content_length": 0,
|
|
"response_time_ms": time.Since(start).Milliseconds(),
|
|
})
|
|
}
|
|
|
|
// Extract response headers
|
|
respHeaders := make(map[string]string)
|
|
for k, v := range resp.Header {
|
|
if len(v) > 0 {
|
|
respHeaders[k] = v[0]
|
|
}
|
|
}
|
|
|
|
elapsed := time.Since(start).Milliseconds()
|
|
logger.Get().Debug(terminal.HiGreen("http_post")+" result", zap.String("url", urlStr), zap.Int("status_code", resp.StatusCode), zap.Int("bodySize", len(respBody)), zap.Int64("response_time_ms", elapsed))
|
|
return vf.vm.ToValue(map[string]interface{}{
|
|
"status_code": resp.StatusCode,
|
|
"error": nil,
|
|
"message": "success",
|
|
"response_headers": respHeaders,
|
|
"response_body": string(respBody),
|
|
"content_length": len(respBody),
|
|
"response_time_ms": elapsed,
|
|
})
|
|
}
|
|
|
|
// randomString generates a random alphanumeric string of given length
|
|
// Usage: randomString(length)
|
|
// Default length is 16 if not specified or invalid
|
|
func (vf *vmFunc) randomString(call goja.FunctionCall) goja.Value {
|
|
length := call.Argument(0).ToInteger()
|
|
logger.Get().Debug("Calling "+terminal.HiGreen("randomString"), zap.Int64("length", length))
|
|
|
|
if length <= 0 {
|
|
length = 16
|
|
}
|
|
|
|
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
rng := mrand.New(mrand.NewSource(time.Now().UnixNano()))
|
|
|
|
b := make([]byte, length)
|
|
for i := range b {
|
|
b[i] = charset[rng.Intn(len(charset))]
|
|
}
|
|
|
|
logger.Get().Debug(terminal.HiGreen("randomString")+" result", zap.Int64("length", length))
|
|
return vf.vm.ToValue(string(b))
|
|
}
|
|
|
|
// uuidFunc generates a UUID v4
|
|
// Usage: uuid()
|
|
func (vf *vmFunc) uuidFunc(call goja.FunctionCall) goja.Value {
|
|
logger.Get().Debug("Calling " + terminal.HiGreen("uuid"))
|
|
id := uuid.New()
|
|
logger.Get().Debug(terminal.HiGreen("uuid")+" result", zap.String("uuid", id.String()))
|
|
return vf.vm.ToValue(id.String())
|
|
}
|
|
|
|
// base64Encode encodes a string to base64
|
|
// Usage: base64Encode(str)
|
|
func (vf *vmFunc) base64Encode(call goja.FunctionCall) goja.Value {
|
|
s := call.Argument(0).String()
|
|
logger.Get().Debug("Calling "+terminal.HiGreen("base64Encode"), zap.Int("inputLength", len(s)))
|
|
|
|
if s == "undefined" {
|
|
logger.Get().Warn("base64Encode: undefined input")
|
|
return vf.vm.ToValue("")
|
|
}
|
|
|
|
encoded := base64.StdEncoding.EncodeToString([]byte(s))
|
|
logger.Get().Debug(terminal.HiGreen("base64Encode")+" result", zap.Int("outputLength", len(encoded)))
|
|
return vf.vm.ToValue(encoded)
|
|
}
|
|
|
|
// base64Decode decodes a base64 string
|
|
// Usage: base64Decode(str)
|
|
func (vf *vmFunc) base64Decode(call goja.FunctionCall) goja.Value {
|
|
s := call.Argument(0).String()
|
|
logger.Get().Debug("Calling "+terminal.HiGreen("base64Decode"), zap.Int("inputLength", len(s)))
|
|
|
|
if s == "undefined" {
|
|
logger.Get().Warn("base64Decode: undefined input")
|
|
return vf.vm.ToValue("")
|
|
}
|
|
|
|
decoded, err := base64.StdEncoding.DecodeString(s)
|
|
if err != nil {
|
|
logger.Get().Warn("base64Decode: failed to decode", zap.Error(err))
|
|
return vf.vm.ToValue("")
|
|
}
|
|
|
|
logger.Get().Debug(terminal.HiGreen("base64Decode")+" result", zap.Int("outputLength", len(decoded)))
|
|
return vf.vm.ToValue(string(decoded))
|
|
}
|
|
|
|
// removeFile removes a file
|
|
// Usage: removeFile(path) -> returns true on success, false on failure
|
|
// Will fail if path is a directory
|
|
func (vf *vmFunc) removeFile(call goja.FunctionCall) goja.Value {
|
|
path := call.Argument(0).String()
|
|
logger.Get().Debug("Calling "+terminal.HiGreen("removeFile"), zap.String("path", path))
|
|
|
|
if path == "undefined" || path == "" {
|
|
logger.Get().Warn("removeFile: empty path provided")
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
|
|
// Check if path is a file (not directory)
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
logger.Get().Warn("removeFile: file does not exist", zap.String("path", path), zap.Error(err))
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
|
|
if info.IsDir() {
|
|
logger.Get().Warn("removeFile: path is a directory, not a file", zap.String("path", path))
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
|
|
err = os.Remove(path)
|
|
if err != nil {
|
|
logger.Get().Warn("removeFile: failed to remove file", zap.String("path", path), zap.Error(err))
|
|
} else {
|
|
logger.Get().Debug(terminal.HiGreen("removeFile")+" result", zap.String("path", path), zap.Bool("success", true))
|
|
}
|
|
return vf.vm.ToValue(err == nil)
|
|
}
|
|
|
|
// removeFolder removes a folder and all its contents
|
|
// Usage: removeFolder(path) -> returns true on success, false on failure
|
|
// Will fail if path is not a directory
|
|
func (vf *vmFunc) removeFolder(call goja.FunctionCall) goja.Value {
|
|
path := call.Argument(0).String()
|
|
logger.Get().Debug("Calling "+terminal.HiGreen("removeFolder"), zap.String("path", path))
|
|
|
|
if path == "undefined" || path == "" {
|
|
logger.Get().Warn("removeFolder: empty path provided")
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
|
|
// Check if path is a directory
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
logger.Get().Warn("removeFolder: path does not exist", zap.String("path", path), zap.Error(err))
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
|
|
if !info.IsDir() {
|
|
logger.Get().Warn("removeFolder: path is not a directory", zap.String("path", path))
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
|
|
err = os.RemoveAll(path)
|
|
if err != nil {
|
|
logger.Get().Warn("removeFolder: failed to remove folder", zap.String("path", path), zap.Error(err))
|
|
} else {
|
|
logger.Get().Debug(terminal.HiGreen("removeFolder")+" result", zap.String("path", path), zap.Bool("success", true))
|
|
}
|
|
return vf.vm.ToValue(err == nil)
|
|
}
|
|
|
|
func (vf *vmFunc) rmRF(call goja.FunctionCall) goja.Value {
|
|
path := call.Argument(0).String()
|
|
logger.Get().Debug("Calling "+terminal.HiGreen("rm_rf"), zap.String("path", path))
|
|
|
|
if path == "undefined" || path == "" {
|
|
logger.Get().Warn("rm_rf: empty path provided")
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
|
|
err := os.RemoveAll(path)
|
|
if err != nil {
|
|
logger.Get().Warn("rm_rf: failed to remove path", zap.String("path", path), zap.Error(err))
|
|
} else {
|
|
logger.Get().Debug(terminal.HiGreen("rm_rf")+" result", zap.String("path", path), zap.Bool("success", true))
|
|
}
|
|
return vf.vm.ToValue(err == nil)
|
|
}
|
|
|
|
func (vf *vmFunc) removeAllExcept(call goja.FunctionCall) goja.Value {
|
|
folder := call.Argument(0).String()
|
|
keep := filepath.FromSlash(call.Argument(1).String())
|
|
logger.Get().Debug("Calling "+terminal.HiGreen("remove_all_except"), zap.String("folder", folder), zap.String("keep", keep))
|
|
|
|
if folder == "undefined" || folder == "" || keep == "undefined" || keep == "" {
|
|
logger.Get().Warn("remove_all_except: empty folder or keep provided")
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
|
|
folderAbs := filepath.Clean(folder)
|
|
info, err := os.Stat(folderAbs)
|
|
if err != nil {
|
|
logger.Get().Warn("remove_all_except: folder does not exist", zap.String("folder", folderAbs), zap.Error(err))
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
if !info.IsDir() {
|
|
logger.Get().Warn("remove_all_except: folder is not a directory", zap.String("folder", folderAbs))
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
|
|
keepAbs := keep
|
|
if !filepath.IsAbs(keepAbs) {
|
|
keepAbs = filepath.Join(folderAbs, keepAbs)
|
|
}
|
|
keepAbs = filepath.Clean(keepAbs)
|
|
|
|
rel, err := filepath.Rel(folderAbs, keepAbs)
|
|
if err != nil {
|
|
logger.Get().Warn("remove_all_except: failed to compute relative keep path", zap.String("folder", folderAbs), zap.String("keep", keepAbs), zap.Error(err))
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
if rel == "." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || rel == ".." {
|
|
logger.Get().Warn("remove_all_except: keep path must be inside folder", zap.String("folder", folderAbs), zap.String("keep", keepAbs))
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
|
|
keepInfo, err := os.Stat(keepAbs)
|
|
if err != nil {
|
|
logger.Get().Warn("remove_all_except: keep file does not exist", zap.String("keep", keepAbs), zap.Error(err))
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
if keepInfo.IsDir() {
|
|
logger.Get().Warn("remove_all_except: keep path is a directory", zap.String("keep", keepAbs))
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
|
|
parts := strings.Split(rel, string(os.PathSeparator))
|
|
if len(parts) == 0 {
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
|
|
if err := pruneExcept(folderAbs, parts); err != nil {
|
|
logger.Get().Warn("remove_all_except: prune failed", zap.String("folder", folderAbs), zap.String("keep", keepAbs), zap.Error(err))
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
|
|
return vf.vm.ToValue(true)
|
|
}
|
|
|
|
func pruneExcept(dir string, keepRelParts []string) error {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(keepRelParts) == 0 {
|
|
return nil
|
|
}
|
|
keepName := keepRelParts[0]
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
fullPath := filepath.Join(dir, name)
|
|
if name != keepName {
|
|
if err := os.RemoveAll(fullPath); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
if len(keepRelParts) > 1 {
|
|
if !entry.IsDir() {
|
|
return fmt.Errorf("keep path parent is not a directory: %s", fullPath)
|
|
}
|
|
if err := pruneExcept(fullPath, keepRelParts[1:]); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// printf prints a message to stdout and returns the message
|
|
// Usage: printf(message) -> string
|
|
func (vf *vmFunc) printf(call goja.FunctionCall) goja.Value {
|
|
msg := call.Argument(0).String()
|
|
logger.Get().Debug("Calling "+terminal.HiGreen("printf"), zap.Int("msgLength", len(msg)))
|
|
|
|
if msg != "undefined" {
|
|
fmt.Println(msg)
|
|
}
|
|
return vf.vm.ToValue(msg)
|
|
}
|
|
|
|
// catFile prints file content to stdout and returns the content
|
|
// Usage: cat_file(path) -> string
|
|
func (vf *vmFunc) catFile(call goja.FunctionCall) goja.Value {
|
|
path := call.Argument(0).String()
|
|
logger.Get().Debug("Calling "+terminal.HiGreen("catFile"), zap.String("path", path))
|
|
|
|
if path == "undefined" || path == "" {
|
|
logger.Get().Warn("catFile: empty path provided")
|
|
return vf.vm.ToValue("")
|
|
}
|
|
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
logger.Get().Warn("catFile: failed to read file", zap.String("path", path), zap.Error(err))
|
|
return vf.vm.ToValue("")
|
|
}
|
|
|
|
logger.Get().Debug(terminal.HiGreen("catFile")+" result", zap.String("path", path), zap.Int("bytes", len(content)))
|
|
fmt.Print(string(content))
|
|
return vf.vm.ToValue(string(content))
|
|
}
|
|
|
|
// exit exits the scan with the given code
|
|
// Usage: exit(code) - 0 for success, non-zero for error
|
|
func (vf *vmFunc) exit(call goja.FunctionCall) goja.Value {
|
|
code := call.Argument(0).ToInteger()
|
|
logger.Get().Debug("Calling "+terminal.HiGreen("exit"), zap.Int64("code", code))
|
|
os.Exit(int(code))
|
|
return goja.Undefined()
|
|
}
|
|
|
|
// skip stops execution of the remaining steps in the current module.
|
|
// The flow continues to the next module. Accepts an optional message.
|
|
// Usage: skip(message?) -> void
|
|
func (vf *vmFunc) skip(call goja.FunctionCall) goja.Value {
|
|
msg := call.Argument(0).String()
|
|
if msg == "undefined" || msg == "" {
|
|
msg = "skip() called"
|
|
}
|
|
logger.Get().Info("Module skip requested: " + msg)
|
|
panic(vf.vm.NewGoError(&SkipModuleError{Message: msg}))
|
|
}
|
|
|
|
// execCmd executes a bash command and returns the stdout output
|
|
// Usage: exec_cmd(command) -> string
|
|
func (vf *vmFunc) execCmd(call goja.FunctionCall) goja.Value {
|
|
command := call.Argument(0).String()
|
|
logger.Get().Debug("Calling "+terminal.HiGreen("execCmd"), zap.String("command", command))
|
|
|
|
if command == "undefined" || command == "" {
|
|
logger.Get().Warn("execCmd: empty command provided")
|
|
return vf.vm.ToValue("")
|
|
}
|
|
|
|
// @NOTE: This is intentional - exec_cmd() is a utility function exposed to workflow
|
|
// definitions for executing shell commands. Input comes from trusted workflow YAML files.
|
|
cmd := exec.Command("sh", "-c", command)
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
logger.Get().Warn("execCmd: command failed", zap.String("command", command), zap.Error(err))
|
|
return vf.vm.ToValue("")
|
|
}
|
|
|
|
logger.Get().Debug(terminal.HiGreen("execCmd")+" result", zap.String("command", command), zap.Int("outputLength", len(output)))
|
|
return vf.vm.ToValue(strings.TrimSpace(string(output)))
|
|
}
|
|
|
|
func (vf *vmFunc) bash(call goja.FunctionCall) goja.Value {
|
|
return vf.execCmd(call)
|
|
}
|
|
|
|
// pythonRunner holds the detected Python binary and whether it's uv.
|
|
type pythonRunner struct {
|
|
bin string // "uv", "python3", or "python"
|
|
isUV bool
|
|
}
|
|
|
|
// 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()
|
|
logger.Get().Debug("Calling "+terminal.HiGreen("execPython"), zap.Int("codeLength", len(code)))
|
|
|
|
if code == "undefined" || code == "" {
|
|
logger.Get().Warn("execPython: empty code provided")
|
|
return vf.vm.ToValue("")
|
|
}
|
|
|
|
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.
|
|
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("runner", runner.bin), zap.Error(err))
|
|
return vf.vm.ToValue("")
|
|
}
|
|
|
|
logger.Get().Debug(terminal.HiGreen("execPython")+" result", zap.Int("outputLength", len(output)))
|
|
return vf.vm.ToValue(strings.TrimSpace(string(output)))
|
|
}
|
|
|
|
// 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()
|
|
logger.Get().Debug("Calling "+terminal.HiGreen("execPythonFile"), zap.String("path", path))
|
|
|
|
if path == "undefined" || path == "" {
|
|
logger.Get().Warn("execPythonFile: empty path provided")
|
|
return vf.vm.ToValue("")
|
|
}
|
|
|
|
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.
|
|
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("runner", runner.bin), zap.String("path", path), zap.Error(err))
|
|
return vf.vm.ToValue("")
|
|
}
|
|
|
|
logger.Get().Debug(terminal.HiGreen("execPythonFile")+" result", zap.String("path", path), zap.Int("outputLength", len(output)))
|
|
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 {
|
|
command := call.Argument(0).String()
|
|
logger.Get().Debug("Calling "+terminal.HiGreen("commandExists"), zap.String("command", command))
|
|
|
|
if command == "undefined" || command == "" {
|
|
logger.Get().Warn("commandExists: empty command provided")
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
|
|
_, err := exec.LookPath(command)
|
|
exists := err == nil
|
|
|
|
logger.Get().Debug(terminal.HiGreen("commandExists")+" result",
|
|
zap.String("command", command),
|
|
zap.Bool("exists", exists))
|
|
return vf.vm.ToValue(exists)
|
|
}
|
|
|
|
// logDebug logs a debug message with [DEBUG] prefix and returns the message
|
|
// Usage: log_debug(message) -> string
|
|
func (vf *vmFunc) logDebug(call goja.FunctionCall) goja.Value {
|
|
msg := call.Argument(0).String()
|
|
logger.Get().Debug("Calling "+terminal.HiGreen("logDebug"), zap.Int("msgLength", len(msg)))
|
|
|
|
if msg != "undefined" {
|
|
fmt.Printf("[DEBUG] %s\n", msg)
|
|
}
|
|
return vf.vm.ToValue(msg)
|
|
}
|
|
|
|
// logInfo logs an info message with [INFO] prefix and returns the message
|
|
// Usage: log_info(message) -> string
|
|
func (vf *vmFunc) logInfo(call goja.FunctionCall) goja.Value {
|
|
msg := call.Argument(0).String()
|
|
logger.Get().Debug("Calling "+terminal.HiGreen("logInfo"), zap.Int("msgLength", len(msg)))
|
|
|
|
if msg != "undefined" {
|
|
fmt.Printf("[INFO] %s\n", msg)
|
|
}
|
|
return vf.vm.ToValue(msg)
|
|
}
|
|
|
|
func (vf *vmFunc) logWarn(call goja.FunctionCall) goja.Value {
|
|
msg := call.Argument(0).String()
|
|
logger.Get().Debug("Calling "+terminal.HiGreen("logWarn"), zap.Int("msgLength", len(msg)))
|
|
|
|
if msg != "undefined" {
|
|
fmt.Printf("%s %s\n", terminal.Yellow("[WARN]"), msg)
|
|
}
|
|
return vf.vm.ToValue(msg)
|
|
}
|
|
|
|
func (vf *vmFunc) logError(call goja.FunctionCall) goja.Value {
|
|
msg := call.Argument(0).String()
|
|
logger.Get().Debug("Calling "+terminal.HiGreen("logError"), zap.Int("msgLength", len(msg)))
|
|
|
|
if msg != "undefined" {
|
|
fmt.Printf("%s %s\n", terminal.Red("[ERROR]"), msg)
|
|
}
|
|
return vf.vm.ToValue(msg)
|
|
}
|
|
|
|
// printGreen prints a message in green color and returns the message
|
|
// Usage: print_green(message) -> string
|
|
func (vf *vmFunc) printGreen(call goja.FunctionCall) goja.Value {
|
|
msg := call.Argument(0).String()
|
|
logger.Get().Debug("Calling "+terminal.HiGreen("printGreen"), zap.Int("msgLength", len(msg)))
|
|
|
|
if msg != "undefined" {
|
|
fmt.Println(terminal.Green(msg))
|
|
}
|
|
return vf.vm.ToValue(msg)
|
|
}
|
|
|
|
// printBlue prints a message in blue color and returns the message
|
|
// Usage: print_blue(message) -> string
|
|
func (vf *vmFunc) printBlue(call goja.FunctionCall) goja.Value {
|
|
msg := call.Argument(0).String()
|
|
logger.Get().Debug("Calling "+terminal.HiGreen("printBlue"), zap.Int("msgLength", len(msg)))
|
|
|
|
if msg != "undefined" {
|
|
fmt.Println(terminal.Blue(msg))
|
|
}
|
|
return vf.vm.ToValue(msg)
|
|
}
|
|
|
|
// printYellow prints a message in yellow color and returns the message
|
|
// Usage: print_yellow(message) -> string
|
|
func (vf *vmFunc) printYellow(call goja.FunctionCall) goja.Value {
|
|
msg := call.Argument(0).String()
|
|
logger.Get().Debug("Calling "+terminal.HiGreen("printYellow"), zap.Int("msgLength", len(msg)))
|
|
|
|
if msg != "undefined" {
|
|
fmt.Println(terminal.Yellow(msg))
|
|
}
|
|
return vf.vm.ToValue(msg)
|
|
}
|
|
|
|
// printRed prints a message in red color and returns the message
|
|
// Usage: print_red(message) -> string
|
|
func (vf *vmFunc) printRed(call goja.FunctionCall) goja.Value {
|
|
msg := call.Argument(0).String()
|
|
logger.Get().Debug("Calling "+terminal.HiGreen("printRed"), zap.Int("msgLength", len(msg)))
|
|
|
|
if msg != "undefined" {
|
|
fmt.Println(terminal.Red(msg))
|
|
}
|
|
return vf.vm.ToValue(msg)
|
|
}
|
|
|
|
// setVar sets a runtime variable that can be retrieved with get_var
|
|
// Also sets the variable on the VM for immediate access in the same execution
|
|
// Usage: set_var(name, value) -> string (returns the value)
|
|
func (vf *vmFunc) setVar(call goja.FunctionCall) goja.Value {
|
|
name := call.Argument(0).String()
|
|
value := call.Argument(1).String()
|
|
logger.Get().Debug("Calling "+terminal.HiGreen("setVar"), zap.String("name", name), zap.String("value", value))
|
|
|
|
if name == "undefined" || name == "" {
|
|
logger.Get().Warn("setVar: name is required")
|
|
return vf.vm.ToValue("")
|
|
}
|
|
|
|
// Handle undefined value as empty string
|
|
if value == "undefined" {
|
|
value = ""
|
|
}
|
|
|
|
ctx := vf.getContext()
|
|
if ctx != nil {
|
|
if ctx.RuntimeVars == nil {
|
|
ctx.RuntimeVars = make(map[string]string)
|
|
}
|
|
ctx.RuntimeVars[name] = value
|
|
// Also set on VM for immediate access in same execution
|
|
_ = vf.vm.Set(name, value)
|
|
}
|
|
|
|
logger.Get().Debug(terminal.HiGreen("setVar")+" result", zap.String("name", name), zap.String("value", value))
|
|
return vf.vm.ToValue(value)
|
|
}
|
|
|
|
// getVar retrieves a runtime variable set with set_var
|
|
// Usage: get_var(name) -> string (returns empty string if not found)
|
|
func (vf *vmFunc) getVar(call goja.FunctionCall) goja.Value {
|
|
name := call.Argument(0).String()
|
|
logger.Get().Debug("Calling "+terminal.HiGreen("getVar"), zap.String("name", name))
|
|
|
|
if name == "undefined" || name == "" {
|
|
logger.Get().Warn("getVar: name is required")
|
|
return vf.vm.ToValue("")
|
|
}
|
|
|
|
ctx := vf.getContext()
|
|
if ctx != nil && ctx.RuntimeVars != nil {
|
|
if val, ok := ctx.RuntimeVars[name]; ok {
|
|
logger.Get().Debug(terminal.HiGreen("getVar")+" result", zap.String("name", name), zap.String("value", val))
|
|
return vf.vm.ToValue(val)
|
|
}
|
|
}
|
|
|
|
logger.Get().Debug(terminal.HiGreen("getVar")+" result (not found)", zap.String("name", name))
|
|
return vf.vm.ToValue("")
|
|
}
|
|
|
|
// sleep pauses execution for the given number of seconds
|
|
// Usage: sleep(seconds) -> void
|
|
func (vf *vmFunc) sleep(call goja.FunctionCall) goja.Value {
|
|
seconds := call.Argument(0).ToInteger()
|
|
logger.Get().Debug("Calling "+terminal.HiGreen("sleep"), zap.Int64("seconds", seconds))
|
|
|
|
if seconds <= 0 {
|
|
seconds = 1
|
|
}
|
|
|
|
time.Sleep(time.Duration(seconds) * time.Second)
|
|
logger.Get().Debug(terminal.HiGreen("sleep")+" completed", zap.Int64("seconds", seconds))
|
|
return goja.Undefined()
|
|
}
|
|
|
|
// saveContent saves content to a file
|
|
// Usage: save_content(content, path) -> bool
|
|
func (vf *vmFunc) saveContent(call goja.FunctionCall) goja.Value {
|
|
content := call.Argument(0).String()
|
|
path := call.Argument(1).String()
|
|
logger.Get().Debug("Calling "+terminal.HiGreen("saveContent"), zap.String("path", path), zap.Int("contentLength", len(content)))
|
|
|
|
if path == "undefined" || path == "" {
|
|
logger.Get().Warn("saveContent: empty path provided")
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
|
|
// Handle undefined content as empty string
|
|
if content == "undefined" {
|
|
content = ""
|
|
}
|
|
|
|
// Ensure parent directory exists
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
logger.Get().Warn("saveContent: failed to create directory", zap.String("dir", dir), zap.Error(err))
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
|
|
// Write content to file
|
|
err := os.WriteFile(path, []byte(content), 0644)
|
|
if err != nil {
|
|
logger.Get().Warn("saveContent: failed to write file", zap.String("path", path), zap.Error(err))
|
|
} else {
|
|
logger.Get().Debug(terminal.HiGreen("saveContent")+" result", zap.String("path", path), zap.Bool("success", true))
|
|
}
|
|
return vf.vm.ToValue(err == nil)
|
|
}
|
|
|
|
// moveFile moves a file from source to destination
|
|
// Uses os.Rename for same-filesystem moves (instant),
|
|
// falls back to buffered copy+delete for cross-filesystem moves
|
|
// Usage: moveFile(source, dest) -> bool
|
|
func (vf *vmFunc) moveFile(call goja.FunctionCall) goja.Value {
|
|
source := call.Argument(0).String()
|
|
dest := call.Argument(1).String()
|
|
logger.Get().Debug("Calling "+terminal.HiGreen("moveFile"),
|
|
zap.String("source", source), zap.String("dest", dest))
|
|
|
|
// Validate arguments
|
|
if source == "undefined" || source == "" || dest == "undefined" || dest == "" {
|
|
logger.Get().Warn("moveFile: empty source or dest provided")
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
|
|
// Check source exists and is a file
|
|
info, err := os.Stat(source)
|
|
if err != nil {
|
|
logger.Get().Warn("moveFile: source does not exist",
|
|
zap.String("source", source), zap.Error(err))
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
if info.IsDir() {
|
|
logger.Get().Warn("moveFile: source is a directory, not a file",
|
|
zap.String("source", source))
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
|
|
// Ensure destination directory exists
|
|
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
|
|
logger.Get().Warn("moveFile: failed to create destination directory",
|
|
zap.String("dest", dest), zap.Error(err))
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
|
|
// Try atomic rename first (fast, same filesystem)
|
|
err = os.Rename(source, dest)
|
|
if err == nil {
|
|
logger.Get().Debug(terminal.HiGreen("moveFile")+" result (renamed)",
|
|
zap.String("source", source), zap.String("dest", dest), zap.Bool("success", true))
|
|
return vf.vm.ToValue(true)
|
|
}
|
|
|
|
// Fallback: buffered copy + delete (cross-filesystem)
|
|
if err := copyFileBuffered(source, dest, info.Mode()); err != nil {
|
|
logger.Get().Warn("moveFile: failed to copy file",
|
|
zap.String("source", source), zap.String("dest", dest), zap.Error(err))
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
|
|
// Remove source after successful copy
|
|
if err := os.Remove(source); err != nil {
|
|
logger.Get().Warn("moveFile: copied but failed to remove source",
|
|
zap.String("source", source), zap.Error(err))
|
|
// Still return true since the file was successfully moved
|
|
}
|
|
|
|
logger.Get().Debug(terminal.HiGreen("moveFile")+" result (copied)",
|
|
zap.String("source", source), zap.String("dest", dest), zap.Bool("success", true))
|
|
return vf.vm.ToValue(true)
|
|
}
|
|
|
|
// snapshotExport exports a workspace as a ZIP snapshot by running osmedeus snapshot export as a subprocess.
|
|
// Usage: snapshot_export(workspace, dest?) -> string (zip path on success, empty on failure)
|
|
func (vf *vmFunc) snapshotExport(call goja.FunctionCall) goja.Value {
|
|
workspace := call.Argument(0).String()
|
|
dest := call.Argument(1).String()
|
|
|
|
if workspace == "undefined" || workspace == "" {
|
|
logger.Get().Warn("snapshot_export: workspace is required")
|
|
return vf.vm.ToValue("")
|
|
}
|
|
if dest == "undefined" {
|
|
dest = ""
|
|
}
|
|
|
|
// Find the current executable
|
|
exePath, err := os.Executable()
|
|
if err != nil {
|
|
logger.Get().Warn("snapshot_export: failed to find executable", zap.Error(err))
|
|
return vf.vm.ToValue("")
|
|
}
|
|
|
|
// Build command arguments
|
|
args := []string{"snapshot", "export", workspace}
|
|
if dest != "" {
|
|
args = append(args, "-o", dest)
|
|
}
|
|
|
|
logger.Get().Debug("Calling "+terminal.HiGreen("snapshot_export"),
|
|
zap.String("workspace", workspace),
|
|
zap.String("dest", dest))
|
|
|
|
// @NOTE: This is intentional - snapshot_export() is a utility function exposed to workflow
|
|
// definitions for exporting workspaces. Input comes from trusted workflow YAML files.
|
|
cmd := exec.Command(exePath, args...)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
logger.Get().Warn("snapshot_export: command failed",
|
|
zap.String("workspace", workspace),
|
|
zap.Error(err),
|
|
zap.String("output", string(output)))
|
|
return vf.vm.ToValue("")
|
|
}
|
|
|
|
// Parse output to extract the zip file path from "File: <path>" line
|
|
var zipPath string
|
|
for _, line := range strings.Split(string(output), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if idx := strings.Index(line, "File:"); idx >= 0 {
|
|
zipPath = strings.TrimSpace(line[idx+len("File:"):])
|
|
break
|
|
}
|
|
}
|
|
|
|
logger.Get().Debug(terminal.HiGreen("snapshot_export")+" result",
|
|
zap.String("workspace", workspace),
|
|
zap.String("zipPath", zipPath))
|
|
return vf.vm.ToValue(zipPath)
|
|
}
|
|
|
|
// snapshotImport imports a workspace from a ZIP snapshot by running osmedeus snapshot import as a subprocess.
|
|
// Usage: snapshot_import(source) -> string (workspace name on success, empty on failure)
|
|
func (vf *vmFunc) snapshotImport(call goja.FunctionCall) goja.Value {
|
|
source := call.Argument(0).String()
|
|
|
|
if source == "undefined" || source == "" {
|
|
logger.Get().Warn("snapshot_import: source is required")
|
|
return vf.vm.ToValue("")
|
|
}
|
|
|
|
// Find the current executable
|
|
exePath, err := os.Executable()
|
|
if err != nil {
|
|
logger.Get().Warn("snapshot_import: failed to find executable", zap.Error(err))
|
|
return vf.vm.ToValue("")
|
|
}
|
|
|
|
// Build command arguments with --force to skip interactive confirmation
|
|
args := []string{"snapshot", "import", source, "--force"}
|
|
|
|
logger.Get().Debug("Calling "+terminal.HiGreen("snapshot_import"),
|
|
zap.String("source", source))
|
|
|
|
// @NOTE: This is intentional - snapshot_import() is a utility function exposed to workflow
|
|
// definitions for importing workspaces. Input comes from trusted workflow YAML files.
|
|
cmd := exec.Command(exePath, args...)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
logger.Get().Warn("snapshot_import: command failed",
|
|
zap.String("source", source),
|
|
zap.Error(err),
|
|
zap.String("output", string(output)))
|
|
return vf.vm.ToValue("")
|
|
}
|
|
|
|
// Parse output to extract workspace name from "Workspace: <name>" line
|
|
var workspaceName string
|
|
for _, line := range strings.Split(string(output), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if idx := strings.Index(line, "Workspace:"); idx >= 0 {
|
|
workspaceName = strings.TrimSpace(line[idx+len("Workspace:"):])
|
|
break
|
|
}
|
|
}
|
|
|
|
logger.Get().Debug(terminal.HiGreen("snapshot_import")+" result",
|
|
zap.String("source", source),
|
|
zap.String("workspace", workspaceName))
|
|
return vf.vm.ToValue(workspaceName)
|
|
}
|
|
|
|
// parseParamsToFlags parses a comma-separated "key=value" string into -p flags.
|
|
// E.g. "threads=10,deep=true" -> ["-p", "threads=10", "-p", "deep=true"]
|
|
func parseParamsToFlags(params string) []string {
|
|
params = strings.TrimSpace(params)
|
|
if params == "" || params == "undefined" {
|
|
return nil
|
|
}
|
|
var flags []string
|
|
for _, pair := range strings.Split(params, ",") {
|
|
pair = strings.TrimSpace(pair)
|
|
if pair == "" || !strings.Contains(pair, "=") {
|
|
continue
|
|
}
|
|
flags = append(flags, "-p", pair)
|
|
}
|
|
return flags
|
|
}
|
|
|
|
// runOsmedeus executes the current osmedeus binary with the given flag (-m or -f), name, target, and optional params.
|
|
// Returns the combined stdout+stderr output as a string.
|
|
func (vf *vmFunc) runOsmedeus(flag, name, target, params, funcName string) goja.Value {
|
|
// Find the current executable
|
|
exePath, err := os.Executable()
|
|
if err != nil {
|
|
logger.Get().Warn(funcName+": failed to find executable", zap.Error(err))
|
|
return vf.vm.ToValue("")
|
|
}
|
|
|
|
// Build command arguments
|
|
args := []string{"run", flag, name, "-t", target}
|
|
args = append(args, parseParamsToFlags(params)...)
|
|
|
|
logger.Get().Debug("Calling "+terminal.HiGreen(funcName),
|
|
zap.String("exe", exePath),
|
|
zap.Strings("args", args))
|
|
|
|
// @NOTE: This is intentional - run_module/run_flow are utility functions exposed to workflow
|
|
// definitions for launching sub-scans. Input comes from trusted workflow YAML files.
|
|
cmd := exec.Command(exePath, args...)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
logger.Get().Warn(funcName+": command failed",
|
|
zap.String("name", name),
|
|
zap.String("target", target),
|
|
zap.Error(err),
|
|
zap.String("output", string(output)))
|
|
}
|
|
|
|
logger.Get().Debug(terminal.HiGreen(funcName)+" result",
|
|
zap.String("name", name),
|
|
zap.String("target", target),
|
|
zap.Int("outputLength", len(output)))
|
|
return vf.vm.ToValue(strings.TrimSpace(string(output)))
|
|
}
|
|
|
|
// runModule runs an osmedeus module as a subprocess
|
|
// Usage: run_module(module, target, params?) -> string
|
|
// params is optional comma-separated key=value pairs: "threads=10,deep=true"
|
|
func (vf *vmFunc) runModule(call goja.FunctionCall) goja.Value {
|
|
module := call.Argument(0).String()
|
|
target := call.Argument(1).String()
|
|
params := call.Argument(2).String()
|
|
|
|
if module == "undefined" || module == "" || target == "undefined" || target == "" {
|
|
logger.Get().Warn("run_module: module and target are required")
|
|
return vf.vm.ToValue("")
|
|
}
|
|
|
|
return vf.runOsmedeus("-m", module, target, params, "run_module")
|
|
}
|
|
|
|
// runFlow runs an osmedeus flow as a subprocess
|
|
// Usage: run_flow(flow, target, params?) -> string
|
|
// params is optional comma-separated key=value pairs: "threads=10,deep=true"
|
|
func (vf *vmFunc) runFlow(call goja.FunctionCall) goja.Value {
|
|
flow := call.Argument(0).String()
|
|
target := call.Argument(1).String()
|
|
params := call.Argument(2).String()
|
|
|
|
if flow == "undefined" || flow == "" || target == "undefined" || target == "" {
|
|
logger.Get().Warn("run_flow: flow and target are required")
|
|
return vf.vm.ToValue("")
|
|
}
|
|
|
|
return vf.runOsmedeus("-f", flow, target, params, "run_flow")
|
|
}
|
|
|
|
// runAgent runs an ACP agent subprocess and returns its output.
|
|
// Usage: run_agent(message, agent_name?) -> string
|
|
// agent_name defaults to "claude-code" if not provided.
|
|
// Uses the registered RunAgentFunc hook to avoid circular imports with the executor package.
|
|
func (vf *vmFunc) runAgent(call goja.FunctionCall) goja.Value {
|
|
message := call.Argument(0).String()
|
|
agentName := call.Argument(1).String()
|
|
|
|
if message == "undefined" || message == "" {
|
|
logger.Get().Warn("run_agent: message is required")
|
|
return vf.vm.ToValue("")
|
|
}
|
|
if agentName == "undefined" {
|
|
agentName = ""
|
|
}
|
|
|
|
logger.Get().Debug("Calling "+terminal.HiGreen("run_agent"),
|
|
zap.String("message_length", fmt.Sprintf("%d", len(message))),
|
|
zap.String("agent", agentName))
|
|
|
|
fn := GetRunAgentFunc()
|
|
if fn == nil {
|
|
logger.Get().Warn("run_agent: agent hook not registered (call functions.RegisterRunAgentFunc first)")
|
|
return vf.vm.ToValue("")
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
|
defer cancel()
|
|
|
|
output, _, err := fn(ctx, message, agentName)
|
|
if err != nil {
|
|
logger.Get().Warn("run_agent failed", zap.Error(err))
|
|
return vf.vm.ToValue("")
|
|
}
|
|
|
|
return vf.vm.ToValue(output)
|
|
}
|
|
|
|
// runOnMaster executes an action on the master node in distributed mode.
|
|
// In distributed mode, the request is sent via Redis to the master.
|
|
// In standalone mode, falls back to local execution.
|
|
//
|
|
// Usage:
|
|
//
|
|
// run_on_master('func', 'db_import_sarif("ws", "/path/file.sarif")') -> bool
|
|
// run_on_master('run', 'subdomain', 'example.com', 'threads=10') -> bool
|
|
// run_on_master('bash', 'nmap -sV target.com') -> bool
|
|
func (vf *vmFunc) runOnMaster(call goja.FunctionCall) goja.Value {
|
|
action := call.Argument(0).String()
|
|
if action == "undefined" || action == "" {
|
|
logger.Get().Warn("run_on_master: action is required (func, run, or bash)")
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
|
|
log := logger.Get()
|
|
|
|
switch action {
|
|
case "func":
|
|
expr := call.Argument(1).String()
|
|
if expr == "undefined" || expr == "" {
|
|
log.Warn("run_on_master: expression is required for 'func' action")
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
|
|
log.Debug("Calling "+terminal.HiGreen("run_on_master"),
|
|
zap.String("action", "func"),
|
|
zap.String("expr", expr))
|
|
|
|
// Try distributed path first
|
|
if trySendExecuteRequest("func", expr, "", "", "", "master", "") {
|
|
log.Debug("run_on_master: sent func request to master via Redis")
|
|
return vf.vm.ToValue(true)
|
|
}
|
|
|
|
// Fallback: execute locally using the current runtime
|
|
log.Warn("run_on_master: not in distributed mode, executing locally",
|
|
zap.String("action", action),
|
|
zap.String("hint", "ensure worker mode is active and Redis is configured"))
|
|
execCtx := make(map[string]interface{})
|
|
_, err := vf.runtime.Execute(expr, execCtx)
|
|
if err != nil {
|
|
log.Warn("run_on_master: local execution failed",
|
|
zap.String("expr", expr),
|
|
zap.Error(err))
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
return vf.vm.ToValue(true)
|
|
|
|
case "run":
|
|
workflow := call.Argument(1).String()
|
|
target := call.Argument(2).String()
|
|
params := call.Argument(3).String()
|
|
|
|
if workflow == "undefined" || workflow == "" || target == "undefined" || target == "" {
|
|
log.Warn("run_on_master: workflow and target are required for 'run' action")
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
if params == "undefined" {
|
|
params = ""
|
|
}
|
|
|
|
log.Debug("Calling "+terminal.HiGreen("run_on_master"),
|
|
zap.String("action", "run"),
|
|
zap.String("workflow", workflow),
|
|
zap.String("target", target),
|
|
zap.String("params", params))
|
|
|
|
// Try distributed path first
|
|
if trySendExecuteRequest("run", "", workflow, target, params, "master", "") {
|
|
log.Debug("run_on_master: sent run request to master via Redis")
|
|
return vf.vm.ToValue(true)
|
|
}
|
|
|
|
// Fallback: run locally as subprocess
|
|
log.Warn("run_on_master: not in distributed mode, running locally",
|
|
zap.String("action", action),
|
|
zap.String("hint", "ensure worker mode is active and Redis is configured"))
|
|
vf.runOsmedeus("-m", workflow, target, params, "run_on_master")
|
|
return vf.vm.ToValue(true)
|
|
|
|
case "bash":
|
|
command := call.Argument(1).String()
|
|
if command == "undefined" || command == "" {
|
|
log.Warn("run_on_master: command is required for 'bash' action")
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
|
|
log.Debug("Calling "+terminal.HiGreen("run_on_master"),
|
|
zap.String("action", "bash"),
|
|
zap.String("command", command))
|
|
|
|
// Try distributed path first
|
|
if trySendExecuteRequest("bash", command, "", "", "", "master", "") {
|
|
log.Debug("run_on_master: sent bash request to master via Redis")
|
|
return vf.vm.ToValue(true)
|
|
}
|
|
|
|
// Fallback: execute locally
|
|
log.Warn("run_on_master: not in distributed mode, executing bash locally")
|
|
// @NOTE: This is intentional - run_on_master('bash', cmd) is a utility function
|
|
// exposed to workflow definitions. Input comes from trusted workflow YAML files.
|
|
cmd := exec.Command("sh", "-c", command)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
log.Warn("run_on_master: local bash execution failed",
|
|
zap.String("command", command), zap.Error(err),
|
|
zap.String("output", string(output)))
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
return vf.vm.ToValue(true)
|
|
|
|
default:
|
|
log.Warn("run_on_master: unknown action",
|
|
zap.String("action", action),
|
|
zap.String("valid", "func, run, bash"))
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
}
|
|
|
|
// runOnWorker executes an action on worker node(s) in distributed mode.
|
|
// In distributed mode, the request is routed via Redis through the master to target workers.
|
|
// In standalone mode, falls back to local execution.
|
|
//
|
|
// Usage:
|
|
//
|
|
// run_on_worker('all', 'func', 'log_info("hello")') -> bool
|
|
// run_on_worker('scanner-1', 'run', 'subdomain', 'example.com', 'threads=10') -> bool
|
|
// run_on_worker('all', 'bash', 'apt update && apt install -y nmap') -> bool
|
|
func (vf *vmFunc) runOnWorker(call goja.FunctionCall) goja.Value {
|
|
scope := call.Argument(0).String()
|
|
if scope == "undefined" || scope == "" {
|
|
scope = "all"
|
|
}
|
|
action := call.Argument(1).String()
|
|
if action == "undefined" || action == "" {
|
|
logger.Get().Warn("run_on_worker: action is required (func, run, bash)")
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
|
|
log := logger.Get()
|
|
|
|
switch action {
|
|
case "func":
|
|
expr := call.Argument(2).String()
|
|
if expr == "undefined" || expr == "" {
|
|
log.Warn("run_on_worker: expression required for 'func' action")
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
if trySendExecuteRequest("func", expr, "", "", "", "worker", scope) {
|
|
return vf.vm.ToValue(true)
|
|
}
|
|
// Fallback: execute locally
|
|
log.Warn("run_on_worker: not in distributed mode, executing locally")
|
|
execCtx := make(map[string]interface{})
|
|
_, err := vf.runtime.Execute(expr, execCtx)
|
|
if err != nil {
|
|
log.Warn("run_on_worker: local execution failed", zap.Error(err))
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
return vf.vm.ToValue(true)
|
|
|
|
case "run":
|
|
workflow := call.Argument(2).String()
|
|
target := call.Argument(3).String()
|
|
params := call.Argument(4).String()
|
|
if workflow == "undefined" || workflow == "" || target == "undefined" || target == "" {
|
|
log.Warn("run_on_worker: workflow and target required for 'run' action")
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
if params == "undefined" {
|
|
params = ""
|
|
}
|
|
if trySendExecuteRequest("run", "", workflow, target, params, "worker", scope) {
|
|
return vf.vm.ToValue(true)
|
|
}
|
|
// Fallback: run locally
|
|
log.Warn("run_on_worker: not in distributed mode, running locally")
|
|
vf.runOsmedeus("-m", workflow, target, params, "run_on_worker")
|
|
return vf.vm.ToValue(true)
|
|
|
|
case "bash":
|
|
command := call.Argument(2).String()
|
|
if command == "undefined" || command == "" {
|
|
log.Warn("run_on_worker: command required for 'bash' action")
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
if trySendExecuteRequest("bash", command, "", "", "", "worker", scope) {
|
|
return vf.vm.ToValue(true)
|
|
}
|
|
// Fallback: execute locally
|
|
log.Warn("run_on_worker: not in distributed mode, executing bash locally")
|
|
// @NOTE: This is intentional - run_on_worker('scope', 'bash', cmd) is a utility function
|
|
// exposed to workflow definitions. Input comes from trusted workflow YAML files.
|
|
cmd := exec.Command("sh", "-c", command)
|
|
_, _ = cmd.CombinedOutput()
|
|
return vf.vm.ToValue(true)
|
|
|
|
default:
|
|
log.Warn("run_on_worker: unknown action (expected 'func', 'run', or 'bash'). "+
|
|
"Did you forget the scope argument? Correct usage: run_on_worker(scope, action, ...args)",
|
|
zap.String("action", action))
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
}
|
|
|
|
// copyFileBuffered copies a file using buffered I/O (memory-efficient for large files)
|
|
func copyFileBuffered(source, dest string, mode os.FileMode) error {
|
|
srcFile, err := os.Open(source)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = srcFile.Close() }()
|
|
|
|
dstFile, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = dstFile.Close() }()
|
|
|
|
_, err = io.Copy(dstFile, srcFile)
|
|
return err
|
|
}
|