Skip to content

Tool: fs_rm #17

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions tools/cmd/exec/exec.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package main

import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)

type execInput struct {
Cmd string `json:"cmd"`
Args []string `json:"args"`
Cwd string `json:"cwd,omitempty"`
Env map[string]string `json:"env,omitempty"`
Stdin string `json:"stdin,omitempty"`
TimeoutSec int `json:"timeoutSec,omitempty"`
}

type execOutput struct {
ExitCode int `json:"exitCode"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
DurationMs int64 `json:"durationMs"`
}

func main() {
in, err := readInput(os.Stdin)
if err != nil {
// Standardized error contract: write single-line JSON to stderr and exit non-zero
msg := sanitizeError(err)
fmt.Fprintf(os.Stderr, "{\"error\":%q}\n", msg)
os.Exit(1)
}

stdout, stderr, exitCode, dur := runCommand(in)
writeOutput(execOutput{ExitCode: exitCode, Stdout: stdout, Stderr: stderr, DurationMs: dur})
}

func readInput(r io.Reader) (execInput, error) {
var in execInput
br := bufio.NewReader(r)
data, err := io.ReadAll(br)
if err != nil {
return in, fmt.Errorf("read stdin: %w", err)
}
if err := json.Unmarshal(data, &in); err != nil {
return in, fmt.Errorf("parse json: %w", err)
}
if strings.TrimSpace(in.Cmd) == "" {
return in, fmt.Errorf("cmd is required")
}
return in, nil
}

func runCommand(in execInput) (stdoutStr, stderrStr string, exitCode int, durationMs int64) {
start := time.Now()
ctx := context.Background()
var cancel context.CancelFunc
if in.TimeoutSec > 0 {
ctx, cancel = context.WithTimeout(ctx, time.Duration(in.TimeoutSec)*time.Second)
defer cancel()
}

cmd := exec.CommandContext(ctx, in.Cmd, in.Args...)
if strings.TrimSpace(in.Cwd) != "" {
// Ensure cwd is clean and absolute if provided as relative
if !filepath.IsAbs(in.Cwd) {
if abs, err := filepath.Abs(in.Cwd); err == nil {
cmd.Dir = abs
} else {
// Fall back to provided value if Abs fails
cmd.Dir = in.Cwd
}
} else {
cmd.Dir = in.Cwd
}
}
// Start from current environment and apply overrides
env := os.Environ()
for k, v := range in.Env {
if strings.Contains(k, "=") {
// Skip invalid keys defensively
continue
}
env = append(env, fmt.Sprintf("%s=%s", k, v))
}
cmd.Env = env

if in.Stdin != "" {
cmd.Stdin = strings.NewReader(in.Stdin)
}
var stdoutBuf, stderrBuf strings.Builder
cmd.Stdout = &stdoutBuf
cmd.Stderr = &stderrBuf

exitCode = 0
err := cmd.Run()
durationMs = time.Since(start).Milliseconds()

stdoutStr = stdoutBuf.String()
stderrStr = stderrBuf.String()

if err == nil {
return
}
// Determine exit code and normalize timeout message
if ctxErr := ctx.Err(); ctxErr == context.DeadlineExceeded {
// Timed out
if ee, ok := err.(*exec.ExitError); ok {
exitCode = ee.ExitCode()
} else {
exitCode = 1
}
if !strings.Contains(strings.ToLower(stderrStr), "timeout") {
if len(stderrStr) > 0 && !strings.HasSuffix(stderrStr, "\n") {
stderrStr += "\n"
}
stderrStr += "timeout"
}
return
}
if ee, ok := err.(*exec.ExitError); ok {
exitCode = ee.ExitCode()
} else {
exitCode = 1
}
return
}

func writeOutput(out execOutput) {
enc, err := json.Marshal(out)
if err != nil {
// Best-effort: emit minimal JSON
fmt.Println("{\"exitCode\":0,\"stdout\":\"\",\"stderr\":\"marshal error\",\"durationMs\":0}")
return
}
// Single line JSON
fmt.Println(string(enc))
}

func sanitizeError(err error) string {
if err == nil {
return ""
}
msg := err.Error()
// Collapse newlines to keep single-line contract
msg = strings.ReplaceAll(msg, "\n", " ")
return msg
}
177 changes: 177 additions & 0 deletions tools/cmd/exec/exec_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
package main_test

import (
"bytes"
"encoding/json"
"os/exec"
"runtime"
"strings"
"testing"

testutil "github.com/hyperifyio/goagent/tools/testutil"
)

// execOutput models the expected stdout JSON contract from tools/exec.go
type execOutput struct {
ExitCode int `json:"exitCode"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
DurationMs int64 `json:"durationMs"`
}

// runExec runs the built exec tool with the given JSON input and decodes stdout.
func runExec(t *testing.T, bin string, input any) execOutput {
t.Helper()
data, err := json.Marshal(input)
if err != nil {
t.Fatalf("marshal input: %v", err)
}
cmd := exec.Command(bin)
cmd.Stdin = bytes.NewReader(data)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
t.Fatalf("exec tool failed to run: %v, stderr=%s", err, stderr.String())
}
// Output must be single-line JSON
out := strings.TrimSpace(stdout.String())
var parsed execOutput
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("failed to parse exec output JSON: %v; raw=%q", err, out)
}
if parsed.DurationMs < 0 {
t.Fatalf("durationMs must be >= 0, got %d", parsed.DurationMs)
}
return parsed
}

// TestExec_InvalidJSON verifies stderr JSON error contract and non-zero exit
func TestExec_InvalidJSON(t *testing.T) {
bin := testutil.BuildTool(t, "exec")
// Run with invalid JSON (not an object)
cmd := exec.Command(bin)
cmd.Stdin = strings.NewReader("not-json")
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err == nil {
t.Fatalf("expected non-zero exit for invalid JSON")
}
// Stderr must be single-line JSON: {"error":"..."}
line := strings.TrimSpace(stderr.String())
if line == "" || !strings.HasPrefix(line, "{") || !strings.HasSuffix(line, "}") || strings.Contains(line, "\n") {
t.Fatalf("stderr not single-line JSON: %q", line)
}
var payload map[string]any
if err := json.Unmarshal([]byte(line), &payload); err != nil {
t.Fatalf("stderr not JSON parseable: %v raw=%q", err, line)
}
if _, ok := payload["error"]; !ok {
t.Fatalf("stderr JSON missing 'error' field: %v", payload)
}
}

func TestExec_SuccessEcho(t *testing.T) {
bin := testutil.BuildTool(t, "exec")
// Use /bin/echo on Unix; on Windows, use cmd /c echo via a small program is complex.
if runtime.GOOS == "windows" {
t.Skip("windows not supported in this test environment")
}
out := runExec(t, bin, map[string]any{
"cmd": "/bin/echo",
"args": []string{"hello"},
})
if out.ExitCode != 0 {
t.Fatalf("expected exitCode 0, got %d (stderr=%q)", out.ExitCode, out.Stderr)
}
if strings.TrimSpace(out.Stdout) != "hello" {
t.Fatalf("unexpected stdout: %q", out.Stdout)
}
}

func TestExec_NonZeroExit(t *testing.T) {
bin := testutil.BuildTool(t, "exec")
if runtime.GOOS == "windows" {
t.Skip("windows not supported in this test environment")
}
// /bin/false exits with code 1
out := runExec(t, bin, map[string]any{
"cmd": "/bin/false",
"args": []string{},
})
if out.ExitCode == 0 {
t.Fatalf("expected non-zero exitCode, got 0")
}
if out.Stdout != "" {
t.Fatalf("expected empty stdout for /bin/false, got %q", out.Stdout)
}
}

func TestExec_Timeout(t *testing.T) {
bin := testutil.BuildTool(t, "exec")
if runtime.GOOS == "windows" {
t.Skip("windows not supported in this test environment")
}
out := runExec(t, bin, map[string]any{
"cmd": "/bin/sleep",
"args": []string{"2"},
"timeoutSec": 1,
})
if out.ExitCode == 0 {
t.Fatalf("expected timeout to produce non-zero exitCode, got 0")
}
if !strings.Contains(strings.ToLower(out.Stderr), "timeout") {
t.Fatalf("stderr should mention timeout, got %q", out.Stderr)
}
if out.DurationMs < 900 || out.DurationMs > 3000 {
t.Fatalf("durationMs out of expected range: %d", out.DurationMs)
}
}

func TestExec_CwdAndEnv(t *testing.T) {
bin := testutil.BuildTool(t, "exec")
if runtime.GOOS == "windows" {
t.Skip("windows not supported in this test environment")
}
tmpDir := t.TempDir()
out := runExec(t, bin, map[string]any{
"cmd": "/bin/pwd",
"args": []string{},
"cwd": tmpDir,
"env": map[string]string{
"FOO": "BAR",
},
})
if strings.TrimSpace(out.Stdout) != tmpDir {
t.Fatalf("pwd did not respect cwd: expected %q, got %q", tmpDir, out.Stdout)
}

// Now verify env via /usr/bin/env
out2 := runExec(t, bin, map[string]any{
"cmd": "/usr/bin/env",
"args": []string{},
"env": map[string]string{
"HELLO": "WORLD",
},
})
if !strings.Contains(out2.Stdout, "HELLO=WORLD") {
t.Fatalf("env var not present in stdout: %q", out2.Stdout)
}
}

func TestExec_StdinPassthrough(t *testing.T) {
bin := testutil.BuildTool(t, "exec")
if runtime.GOOS == "windows" {
t.Skip("windows not supported in this test environment")
}
out := runExec(t, bin, map[string]any{
"cmd": "/bin/cat",
"args": []string{},
"stdin": "xyz",
})
if out.Stdout != "xyz" {
t.Fatalf("stdin passthrough failed, got %q", out.Stdout)
}
}
Loading