Init Project
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s

This commit is contained in:
enkly
2026-10-30 18:06:55 +01:00
parent da8af794bd
commit 79ce8dc4c0
211 changed files with 44434 additions and 1 deletions

View File

@@ -0,0 +1,23 @@
# See services/iam/Dockerfile for the pattern explanation. Workspace
# additionally needs common CLI tools available inside the container
# for run_bash_command (git, go, node, python...) — extend this image's
# second stage as your allowlist (ENKLY_BASH_ALLOWLIST) requires; kept
# minimal here since the allowlist itself is operator-configured.
FROM golang:1.23-bookworm AS build
WORKDIR /src
COPY go.mod go.sum* ./
COPY gen/ gen/
COPY pkg/ pkg/
COPY services/workspace/ services/workspace/
RUN go mod tidy
RUN CGO_ENABLED=0 go build -o /out/workspace-server ./services/workspace/cmd/server
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates git && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /out/workspace-server ./workspace-server
ENV ENKLY_WORKSPACE_GRPC_ADDR=:9004
ENV ENKLY_WORKSPACE=/data/workspace
VOLUME ["/data/workspace"]
EXPOSE 9004
ENTRYPOINT ["./workspace-server"]

View File

@@ -0,0 +1,96 @@
// Command server runs the workspace microservice: a gRPC server
// exposing api/proto/workspace/v1 over the ENKLY_WORKSPACE directory on
// its own volume. Replaces internal/llm/tools.go + sandbox.go (as
// agent tool executors) and internal/handlers/files.go (as the human
// upload/download/tree backend).
package main
import (
"context"
"log/slog"
"net"
"os"
"os/signal"
"strings"
"syscall"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/health"
healthpb "google.golang.org/grpc/health/grpc_health_v1"
"google.golang.org/grpc/reflection"
workspacev1 "enkly/gen/workspace/v1"
"enkly/pkg/envkit"
"enkly/services/workspace/internal/infrastructure/sandboxfs"
grpcsrv "enkly/services/workspace/internal/interfaces/grpc"
)
func main() {
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
if err := envkit.LoadDotEnv(".env"); err != nil {
logger.Error("load .env", "err", err)
os.Exit(1)
}
var (
grpcAddr = envkit.String("ENKLY_WORKSPACE_GRPC_ADDR", ":9004")
workspaceRoot = envkit.String("ENKLY_WORKSPACE", "./workspace")
bashTimeoutSeconds = envkit.Int("ENKLY_BASH_TIMEOUT_SECONDS", 0)
bashAllowlistRaw = envkit.String("ENKLY_BASH_ALLOWLIST", "")
)
toolbox, err := sandboxfs.NewToolbox(workspaceRoot)
if err != nil {
logger.Error("init toolbox", "err", err)
os.Exit(1)
}
if bashTimeoutSeconds > 0 {
toolbox.CommandTimeout = time.Duration(bashTimeoutSeconds) * time.Second
}
// ENKLY_BASH_ALLOWLIST: comma-separated base binaries (first token
// only) run_bash_command may execute. Subcommand rules for go/git
// live in code (sandbox.go), not in this list — unchanged from the
// monolith.
if strings.TrimSpace(bashAllowlistRaw) != "" {
list, err := sandboxfs.ParseAllowlist(bashAllowlistRaw)
if err != nil {
logger.Error("invalid ENKLY_BASH_ALLOWLIST", "err", err)
os.Exit(1)
}
toolbox.AllowedCommands = list
}
logger.Info("workspace ready", "root", toolbox.WorkspaceRoot, "bash_timeout", toolbox.CommandTimeout)
lis, err := net.Listen("tcp", grpcAddr)
if err != nil {
logger.Error("listen", "addr", grpcAddr, "err", err)
os.Exit(1)
}
// MaxRecvMsgSize/MaxSendMsgSize raised above gRPC's 4 MiB default:
// UploadFile/DownloadFile/WriteFile can legitimately carry a
// multi-megabyte file (see maxUploadFileBytes in the gateway's
// files handler, which is also 20 MiB).
grpcServer := grpc.NewServer(grpc.MaxRecvMsgSize(24<<20), grpc.MaxSendMsgSize(24<<20))
workspacev1.RegisterWorkspaceServiceServer(grpcServer, grpcsrv.New(toolbox))
healthSrv := health.NewServer()
healthpb.RegisterHealthServer(grpcServer, healthSrv)
healthSrv.SetServingStatus("", healthpb.HealthCheckResponse_SERVING)
reflection.Register(grpcServer)
go func() {
logger.Info("workspace service listening", "addr", grpcAddr)
if err := grpcServer.Serve(lis); err != nil {
logger.Error("serve", "err", err)
os.Exit(1)
}
}()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
<-ctx.Done()
logger.Info("shutting down")
grpcServer.GracefulStop()
}

View File

@@ -0,0 +1,78 @@
package sandboxfs
import "context"
// The methods below are thin, exported wrappers around the Toolbox's
// unexported per-tool methods (readFile, writeFile, ...), built so the
// gRPC layer (internal/interfaces/grpc) can call them without needing
// package-private access. Each one keeps the original tool's exact
// map[string]any argument contract internally — this is deliberate:
// agent-service tool-calling, the gRPC layer here, and the underlying
// tool implementation all agree on the same argument names
// ("old_string", "replace_all", "path_glob", ...), so nothing about the
// monolith's tool behavior (including its exact success/error message
// text, which the model has been prompted around) has to change.
func (t *Toolbox) ReadFile(path string, offset, limit int) ToolResult {
args := map[string]any{"path": path}
if offset != 0 {
args["offset"] = float64(offset)
}
if limit != 0 {
args["limit"] = float64(limit)
}
return t.readFile(ToolCallRequest{Name: "read_file", Arguments: args})
}
func (t *Toolbox) WriteFile(path, content string) ToolResult {
return t.writeFile(ToolCallRequest{Name: "write_file", Arguments: map[string]any{
"path": path, "content": content,
}})
}
func (t *Toolbox) EditFile(path, oldString, newString string, replaceAll bool) ToolResult {
return t.editFile(ToolCallRequest{Name: "edit_file", Arguments: map[string]any{
"path": path, "old_string": oldString, "new_string": newString, "replace_all": replaceAll,
}})
}
// WorkspaceEdit mirrors one element of multi_edit_file's "edits" array.
type WorkspaceEdit struct {
OldString string
NewString string
ReplaceAll bool
}
func (t *Toolbox) MultiEditFile(path string, edits []WorkspaceEdit) ToolResult {
rawEdits := make([]any, len(edits))
for i, e := range edits {
rawEdits[i] = map[string]any{
"old_string": e.OldString, "new_string": e.NewString, "replace_all": e.ReplaceAll,
}
}
return t.multiEditFile(ToolCallRequest{Name: "multi_edit_file", Arguments: map[string]any{
"path": path, "edits": rawEdits,
}})
}
func (t *Toolbox) ListDirectory(path string) ToolResult {
return t.listDirectory(ToolCallRequest{Name: "list_directory", Arguments: map[string]any{"path": path}})
}
func (t *Toolbox) GlobSearch(pattern, path string) ToolResult {
return t.globSearch(ToolCallRequest{Name: "glob_search", Arguments: map[string]any{
"pattern": pattern, "path": path,
}})
}
func (t *Toolbox) Grep(pattern, path, pathGlob string, caseInsensitive bool) ToolResult {
return t.grep(ToolCallRequest{Name: "grep", Arguments: map[string]any{
"pattern": pattern, "path": path, "path_glob": pathGlob, "case_insensitive": caseInsensitive,
}})
}
func (t *Toolbox) RunBashCommand(ctx context.Context, command string) ToolResult {
return t.runBashCommand(ctx, ToolCallRequest{Name: "run_bash_command", Arguments: map[string]any{
"command": command,
}})
}

View File

@@ -0,0 +1,437 @@
package sandboxfs
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// This file implements the run_bash_command tool's sandbox. The name is
// historical: nothing here ever invokes a shell. The model's command
// string is tokenized into an argv slice and executed directly with
// os/exec, so there is no shell to inject into.
//
// Layers of defense, outermost first:
//
// 1. Tokenizer: a command string containing an unquoted shell
// metacharacter (| & ; < > ` $ or a newline) is rejected with a clear
// message instead of being silently passed through as literal
// arguments, which would confuse the model.
// 2. Allowlist on the base binary ONLY (first token, no path separators).
// Subcommand-level restrictions (go, git) are enforced separately, in
// code, below - never by string-matching the allowlist.
// 3. Per-binary flag denylist for flags that execute other programs or
// follow symlinks (find -exec, go test -exec, grep -R, ...).
// 4. Path validation: every argument that could be a path is resolved
// (symlinks included) and must land inside WorkspaceRoot.
// 5. A scrubbed environment (no DB DSN / SMTP / Stripe secrets reach the
// child), a wall-clock timeout, and a cap on captured output.
//
// Residual risk, stated plainly: `go test` and `go build` compile and run
// workspace code, and that code can do anything the enkly process can.
// Run enkly in a container (docker-compose.yml does) for real isolation.
// DefaultAllowedCommands is the default set of base binaries the agent may
// run. Override with ENKLY_BASH_ALLOWLIST (comma-separated base binaries).
var DefaultAllowedCommands = []string{"go", "git", "ls", "cat", "grep", "find", "gofmt"}
// allowedSubcommands restricts binaries that have dangerous subcommands.
// A binary that is not a key here has no subcommand restriction.
var allowedSubcommands = map[string]map[string]bool{
"go": {"build": true, "test": true, "vet": true, "fmt": true, "list": true, "version": true, "mod": true},
"git": {"status": true, "diff": true, "log": true, "show": true, "branch": true,
"rev-parse": true, "ls-files": true, "blame": true},
}
// deniedFlags are flags (matched exactly, or as a "--flag=value" prefix)
// that let an otherwise-harmless binary run other programs or follow
// symlinks out of the workspace.
var deniedFlags = map[string][]string{
"find": {"-exec", "-execdir", "-ok", "-okdir", "-delete", "-fprint", "-fprint0", "-fprintf", "-fls", "-L", "-H", "-follow"},
"grep": {"-R", "--dereference-recursive"},
"go": {"-exec", "-toolexec", "-vettool", "-overlay", "-modfile"},
"git": {"--output", "--ext-diff", "--textconv", "-c", "--exec-path", "--git-dir", "--work-tree"},
}
// maxCommandOutput bounds how much stdout+stderr is captured. Anything
// beyond it is dropped with a marker, so a runaway `cat` of a huge file
// can't balloon memory or flood the model's context.
const maxCommandOutput = 64 * 1024
// ParseAllowlist parses ENKLY_BASH_ALLOWLIST. Entries are base binaries
// only; anything with a space or a path separator is rejected so the
// ambiguous "go vet" / "go test" style entries can't creep back in.
func ParseAllowlist(raw string) ([]string, error) {
var out []string
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if strings.ContainsAny(part, " \t/\\") {
return nil, fmt.Errorf("allowlist entry %q must be a bare binary name (subcommand rules live in code)", part)
}
out = append(out, part)
}
if len(out) == 0 {
return nil, errors.New("allowlist is empty")
}
return out, nil
}
func (t *Toolbox) allowed(bin string) bool {
list := t.AllowedCommands
if len(list) == 0 {
list = DefaultAllowedCommands
}
for _, a := range list {
if a == bin {
return true
}
}
return false
}
// tokenize splits a command line into argv, honoring single quotes,
// double quotes and backslash escapes, and refusing unquoted shell
// metacharacters.
func tokenize(s string) ([]string, error) {
var (
args []string
cur strings.Builder
inTok bool
quote rune
escaped bool
)
flush := func() {
if inTok {
args = append(args, cur.String())
cur.Reset()
inTok = false
}
}
for _, r := range s {
switch {
case escaped:
cur.WriteRune(r)
inTok = true
escaped = false
case quote == '\'':
if r == '\'' {
quote = 0
} else {
cur.WriteRune(r)
}
case quote == '"':
switch r {
case '"':
quote = 0
case '\\':
escaped = true
case '$', '`':
return nil, fmt.Errorf("%q is not supported inside double quotes (no shell expansion happens here); use single quotes", string(r))
default:
cur.WriteRune(r)
}
case r == '\\':
escaped = true
inTok = true
case r == '\'' || r == '"':
quote = r
inTok = true
case r == ' ' || r == '\t':
flush()
case strings.ContainsRune("|&;<>`$\n\r*?", r):
return nil, fmt.Errorf("shell syntax %q is not supported: commands run directly, without a shell (no pipes, redirects, globbing, substitution or chaining) - run one simple command per call", string(r))
default:
cur.WriteRune(r)
inTok = true
}
}
if quote != 0 {
return nil, errors.New("unterminated quote")
}
if escaped {
return nil, errors.New("trailing backslash")
}
flush()
return args, nil
}
func flagDenied(bin, arg string) bool {
for _, d := range deniedFlags[bin] {
if arg == d || strings.HasPrefix(arg, d+"=") {
return true
}
}
return false
}
// checkArgs validates the argv (already known to start with an allowlisted
// binary) and returns a user-facing error describing the first violation.
func (t *Toolbox) checkArgs(argv []string) error {
bin := argv[0]
rest := argv[1:]
if subs, ok := allowedSubcommands[bin]; ok {
if len(rest) == 0 {
return fmt.Errorf("%s needs a subcommand; allowed: %s", bin, sortedKeys(subs))
}
if !subs[rest[0]] {
return fmt.Errorf("%s %s is not allowed; allowed subcommands: %s", bin, rest[0], sortedKeys(subs))
}
}
// grep's first positional argument is a regex, not a path (unless the
// pattern came from -e/--regexp, in which case every positional is a path).
skipPattern := bin == "grep"
for _, a := range rest {
if a == "-e" || strings.HasPrefix(a, "--regexp") {
skipPattern = false
}
}
for _, a := range rest {
if flagDenied(bin, a) {
return fmt.Errorf("flag %s is not allowed for %s", a, bin)
}
if strings.HasPrefix(a, "-") && len(a) > 1 {
// Flag. Validate a value glued on with "=" or a slash.
if k := strings.Index(a, "="); k >= 0 {
if err := t.checkPathArg(a[k+1:]); err != nil {
return err
}
} else if k := strings.Index(a, "/"); k >= 0 {
if err := t.checkPathArg(a[k:]); err != nil {
return err
}
}
continue
}
if skipPattern {
// The first non-flag argument to grep is the pattern.
skipPattern = false
continue
}
// `go test ./...`-style package patterns and git revisions are not
// files, but they are also harmless to run through the path check:
// they resolve inside the workspace or don't exist.
if err := t.checkPathArg(a); err != nil {
return err
}
}
return nil
}
func sortedKeys(m map[string]bool) string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return strings.Join(keys, ", ")
}
// checkPathArg rejects an argument that resolves outside WorkspaceRoot.
// Arguments that are not paths at all (a package name, a git revision)
// simply don't exist on disk and pass as long as they don't look like an
// escape (absolute or containing "..").
func (t *Toolbox) checkPathArg(arg string) error {
if arg == "" {
return nil
}
root, err := filepath.EvalSymlinks(t.WorkspaceRoot)
if err != nil {
return fmt.Errorf("workspace root: %w", err)
}
var full string
if filepath.IsAbs(arg) {
full = filepath.Clean(arg)
} else {
full = filepath.Join(t.WorkspaceRoot, arg)
}
looksLikeEscape := filepath.IsAbs(arg) || hasDotDot(arg)
resolved, exists := evalExisting(full)
if !exists && !looksLikeEscape {
return nil
}
if !within(root, resolved) {
return fmt.Errorf("argument %q resolves outside the workspace; use paths inside the project", arg)
}
return nil
}
func hasDotDot(p string) bool {
for _, seg := range strings.FieldsFunc(p, func(r rune) bool { return r == '/' || r == '\\' }) {
if seg == ".." {
return true
}
}
return false
}
// evalExisting resolves symlinks in the longest existing prefix of p and
// re-appends the non-existent tail. exists reports whether the whole path
// exists.
func evalExisting(p string) (resolved string, exists bool) {
if r, err := filepath.EvalSymlinks(p); err == nil {
return r, true
}
tail := ""
cur := p
for {
parent := filepath.Dir(cur)
tail = filepath.Join(filepath.Base(cur), tail)
if r, err := filepath.EvalSymlinks(parent); err == nil {
return filepath.Join(r, tail), false
}
if parent == cur {
return p, false
}
cur = parent
}
}
// within reports whether path is root itself or nested under it, comparing
// whole path segments (so /ws-evil is NOT within /ws).
func within(root, path string) bool {
rel, err := filepath.Rel(root, path)
if err != nil {
return false
}
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
}
// cappedBuffer is a mutex-guarded writer that stops growing at max bytes.
type cappedBuffer struct {
mu sync.Mutex
buf bytes.Buffer
max int
truncated bool
}
func (c *cappedBuffer) Write(p []byte) (int, error) {
c.mu.Lock()
defer c.mu.Unlock()
room := c.max - c.buf.Len()
if room <= 0 {
c.truncated = true
return len(p), nil // swallow, so the child never blocks on a full pipe
}
if len(p) > room {
c.buf.Write(p[:room])
c.truncated = true
return len(p), nil
}
return c.buf.Write(p)
}
func (c *cappedBuffer) String() string {
c.mu.Lock()
defer c.mu.Unlock()
s := strings.ToValidUTF8(c.buf.String(), "\uFFFD")
if c.truncated {
s += fmt.Sprintf("\n... [output truncated at %d bytes]", c.max)
}
return s
}
// scrubbedEnv builds the child's environment from a small allowlist so
// secrets held by the enkly process (DB DSN, SMTP and Stripe keys) never
// reach a command the model controls.
func (t *Toolbox) scrubbedEnv() []string {
keep := []string{"PATH", "HOME", "LANG", "TMPDIR", "GOPATH", "GOCACHE", "GOMODCACHE", "GOROOT"}
env := make([]string, 0, len(keep)+6)
for _, k := range keep {
if v, ok := os.LookupEnv(k); ok {
env = append(env, k+"="+v)
}
}
env = append(env,
"GOPROXY=off", // the stack must not need the internet
"GOTOOLCHAIN=local",
"GIT_TERMINAL_PROMPT=0",
"GIT_CONFIG_NOSYSTEM=1",
"GIT_PAGER=cat",
"PAGER=cat",
)
return env
}
func (t *Toolbox) runBashCommand(ctx context.Context, call ToolCallRequest) ToolResult {
fail := func(format string, a ...any) ToolResult {
return ToolResult{Name: call.Name, Content: "run_bash_command: " + fmt.Sprintf(format, a...), IsError: true}
}
command, _ := call.Arguments["command"].(string)
if strings.TrimSpace(command) == "" {
return fail("empty command")
}
argv, err := tokenize(command)
if err != nil {
return fail("%v", err)
}
if len(argv) == 0 {
return fail("empty command")
}
bin := argv[0]
if strings.ContainsAny(bin, `/\`) {
return fail("run the binary by name (e.g. %q), not by path", filepath.Base(bin))
}
if !t.allowed(bin) {
list := t.AllowedCommands
if len(list) == 0 {
list = DefaultAllowedCommands
}
return fail("%q is not an allowed command; allowed: %s", bin, strings.Join(list, ", "))
}
if err := t.checkArgs(argv); err != nil {
return fail("%v", err)
}
timeout := t.CommandTimeout
if timeout <= 0 {
timeout = DefaultCommandTimeout
}
cmdCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
// argv slice, never a shell string.
cmd := exec.CommandContext(cmdCtx, bin, argv[1:]...)
cmd.Dir = t.WorkspaceRoot
cmd.Env = t.scrubbedEnv()
cmd.WaitDelay = 2 * time.Second
out := &cappedBuffer{max: maxCommandOutput}
cmd.Stdout = out
cmd.Stderr = out
runErr := cmd.Run()
text := out.String()
if runErr != nil {
msg := runErr.Error()
if errors.Is(cmdCtx.Err(), context.DeadlineExceeded) {
msg = fmt.Sprintf("timed out after %s", timeout)
} else if errors.Is(runErr, exec.ErrNotFound) {
msg = fmt.Sprintf("%s is not installed in this environment", bin)
}
return ToolResult{
Name: call.Name,
Content: fmt.Sprintf("exit error: %s\n\n%s", msg, text),
IsError: true,
}
}
return ToolResult{Name: call.Name, Content: text}
}

View File

@@ -0,0 +1,764 @@
package sandboxfs
import (
"bufio"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
// ignoredDirNames are skipped by every tool that walks the workspace tree
// (list_directory, glob_search, grep) — dependency/build/VCS directories
// that are almost never useful to an agent browsing a codebase and can be
// enormous (node_modules, vendor, .git), which would otherwise blow past
// the result caps below on the first call.
var ignoredDirNames = map[string]bool{
".git": true, "node_modules": true, "vendor": true, ".venv": true,
"venv": true, "__pycache__": true, "dist": true, "build": true,
".idea": true, ".vscode": true, "target": true, ".next": true,
}
const (
// maxReadLines/maxReadFileBytes bound read_file: a local model's
// context window is small relative to a real codebase, so reading an
// entire large generated file (a lockfile, a minified bundle) would
// silently crowd out everything else in the conversation. offset/
// limit let the model page through a large file deliberately instead.
maxReadLines = 2000
maxReadFileSize = 5 * 1024 * 1024 // 5 MiB
maxWalkEntries = 500 // cap for list_directory / glob_search results
maxGrepMatches = 200
maxGrepFileSize = 2 * 1024 * 1024
// diffContextLines is how many unchanged lines of context are shown
// before and after each changed block in the diff preview that
// edit_file/multi_edit_file return — enough to orient a reviewer
// (human or model) without dumping the whole file back into context,
// mirroring how a frontier coding agent confirms an edit.
diffContextLines = 4
// maxHunkChangedLines caps how many changed (-/+) lines a single hunk
// prints before eliding the middle, so replacing a huge block doesn't
// flood the tool result.
maxHunkChangedLines = 120
// maxDiffHunksPerEdit caps how many occurrences of a replace_all edit
// get a full diff hunk; beyond that, later occurrences are just
// counted, not rendered.
maxDiffHunksPerEdit = 8
)
// Toolbox executes the filesystem/bash tools on the host machine,
// jailed to WorkspaceRoot. This is the same Toolbox the monolith's
// internal/llm package had — minus Defs()/Execute() (tool-schema
// presentation and dispatch are now the agent service's job; see its
// internal/infrastructure/toolcatalog) and minus the Searcher field
// (search_codebase is now a direct agent-to-knowledge-service gRPC
// call, not something the workspace service's toolbox mediates).
type Toolbox struct {
WorkspaceRoot string
// CommandTimeout bounds how long run_bash_command may run. Overridable
// per Toolbox (see NewToolbox / ENKLY_BASH_TIMEOUT_SECONDS in main.go)
// since a "go test ./..." or a from-scratch npm install can easily
// outrun a timeout tuned for quick commands.
CommandTimeout time.Duration
// AllowedCommands is the allowlist of base binaries run_bash_command
// may execute (first token only; see sandbox.go). Empty means
// DefaultAllowedCommands.
AllowedCommands []string
}
// DefaultCommandTimeout is how long run_bash_command may run when
// ENKLY_BASH_TIMEOUT_SECONDS isn't set — long enough for a typical build
// or test run, short enough that a genuinely hung command (e.g. a
// process waiting on stdin) doesn't stall the whole agent turn.
const DefaultCommandTimeout = 60 * time.Second
// NewToolbox builds a Toolbox rooted at workspaceRoot (created if missing).
func NewToolbox(workspaceRoot string) (*Toolbox, error) {
abs, err := filepath.Abs(workspaceRoot)
if err != nil {
return nil, fmt.Errorf("tools: resolve workspace root: %w", err)
}
if err := os.MkdirAll(abs, 0o755); err != nil {
return nil, fmt.Errorf("tools: create workspace root: %w", err)
}
return &Toolbox{
WorkspaceRoot: abs,
CommandTimeout: DefaultCommandTimeout,
}, nil
}
func (t *Toolbox) resolvePath(rel string) (string, error) {
cleaned := filepath.Clean("/" + rel) // force it to look absolute-from-root, collapsing ".."
full := filepath.Join(t.WorkspaceRoot, cleaned)
// Compare whole path segments: a plain HasPrefix would accept a sibling
// directory like "<root>-evil".
if !within(t.WorkspaceRoot, full) {
return "", fmt.Errorf("path %q escapes the workspace root", rel)
}
// Symlinks inside the workspace must not lead out of it.
realRoot, err := filepath.EvalSymlinks(t.WorkspaceRoot)
if err != nil {
return "", fmt.Errorf("workspace root: %w", err)
}
if resolved, _ := evalExisting(full); !within(realRoot, resolved) {
return "", fmt.Errorf("path %q escapes the workspace root via a symlink", rel)
}
return full, nil
}
// ResolvePath is the exported form of resolvePath, for callers outside
// this package that need the exact same jail the read_file/write_file/
// etc. tools use — namely the file upload/download HTTP handlers in
// internal/handlers, which save/serve files under WorkspaceRoot and must
// never let a client-supplied path escape it. Keeping one implementation
// here (rather than a second copy of the containment check in handlers)
// means both the agent's tools and the HTTP endpoints stay in lockstep if
// the jailing logic ever changes.
func (t *Toolbox) ResolvePath(rel string) (string, error) {
return t.resolvePath(rel)
}
// argInt reads an optional numeric argument. A tool call's "arguments"
// JSON string is decoded via encoding/json into map[string]any, which
// always produces float64 for a JSON number regardless of the schema's
// declared type, so this accepts float64 (the common case) and falls
// back to a string (seen from some models that emit stringified
// numbers) before giving up.
func argInt(args map[string]any, key string) (int, bool) {
v, ok := args[key]
if !ok || v == nil {
return 0, false
}
switch n := v.(type) {
case float64:
return int(n), true
case int:
return n, true
case string:
if i, err := strconv.Atoi(strings.TrimSpace(n)); err == nil {
return i, true
}
}
return 0, false
}
func argBool(args map[string]any, key string) bool {
v, _ := args[key].(bool)
return v
}
func (t *Toolbox) readFile(call ToolCallRequest) ToolResult {
relPath, _ := call.Arguments["path"].(string)
full, err := t.resolvePath(relPath)
if err != nil {
return ToolResult{Name: call.Name, Content: err.Error(), IsError: true}
}
info, err := os.Stat(full)
if err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("read_file: %v", err), IsError: true}
}
if info.IsDir() {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("read_file: %q is a directory, not a file", relPath), IsError: true}
}
if info.Size() > maxReadFileSize {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("read_file: %q is %d bytes, larger than the %d-byte limit — use run_bash_command (e.g. sed/head/grep) to inspect it in pieces instead", relPath, info.Size(), maxReadFileSize), IsError: true}
}
data, err := os.ReadFile(full)
if err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("read_file: %v", err), IsError: true}
}
offset, _ := argInt(call.Arguments, "offset")
if offset < 1 {
offset = 1
}
limit, ok := argInt(call.Arguments, "limit")
if !ok || limit <= 0 || limit > maxReadLines {
limit = maxReadLines
}
lines := strings.Split(string(data), "\n")
if offset > len(lines) {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("read_file: offset %d is past end of file (%d lines)", offset, len(lines)), IsError: true}
}
end := offset - 1 + limit
if end > len(lines) {
end = len(lines)
}
var sb strings.Builder
for i := offset - 1; i < end; i++ {
fmt.Fprintf(&sb, "%6d\t%s\n", i+1, lines[i])
}
if end < len(lines) {
fmt.Fprintf(&sb, "\n… %d more lines not shown; re-call read_file with offset=%d to continue …\n", len(lines)-end, end+1)
}
return ToolResult{Name: call.Name, Content: sb.String()}
}
func (t *Toolbox) writeFile(call ToolCallRequest) ToolResult {
relPath, _ := call.Arguments["path"].(string)
content, _ := call.Arguments["content"].(string)
full, err := t.resolvePath(relPath)
if err != nil {
return ToolResult{Name: call.Name, Content: err.Error(), IsError: true}
}
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("write_file: mkdir: %v", err), IsError: true}
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("write_file: %v", err), IsError: true}
}
return ToolResult{Name: call.Name, Content: fmt.Sprintf("wrote %d bytes to %s", len(content), relPath)}
}
func (t *Toolbox) editFile(call ToolCallRequest) ToolResult {
relPath, _ := call.Arguments["path"].(string)
oldStr, _ := call.Arguments["old_string"].(string)
newStr, _ := call.Arguments["new_string"].(string)
replaceAll := argBool(call.Arguments, "replace_all")
if oldStr == "" {
return ToolResult{Name: call.Name, Content: "edit_file: old_string must not be empty (use write_file to create a new file)", IsError: true}
}
if oldStr == newStr {
return ToolResult{Name: call.Name, Content: "edit_file: old_string and new_string are identical — nothing to do", IsError: true}
}
full, err := t.resolvePath(relPath)
if err != nil {
return ToolResult{Name: call.Name, Content: err.Error(), IsError: true}
}
data, err := os.ReadFile(full)
if err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("edit_file: %v", err), IsError: true}
}
content := string(data)
count := strings.Count(content, oldStr)
switch {
case count == 0:
return ToolResult{Name: call.Name, Content: fmt.Sprintf("edit_file: old_string not found in %s — it must match the file's current content exactly (re-read the file if you're not sure)", relPath), IsError: true}
case count > 1 && !replaceAll:
return ToolResult{Name: call.Name, Content: fmt.Sprintf("edit_file: old_string appears %d times in %s, so the edit is ambiguous — include more surrounding context to make it unique, or pass replace_all=true if you really mean every occurrence", count, relPath), IsError: true}
}
updated, hunks := applyEditWithDiff(content, oldStr, newStr, replaceAll)
if err := os.WriteFile(full, []byte(updated), 0o644); err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("edit_file: write: %v", err), IsError: true}
}
verb := "1 occurrence"
if replaceAll && count > 1 {
verb = fmt.Sprintf("%d occurrences", count)
}
header := fmt.Sprintf("edited %s: replaced %s\n\n", relPath, verb)
return ToolResult{Name: call.Name, Content: header + renderDiffHunks(hunks)}
}
// multiEditFile applies a sequence of edit_file-style replacements to one
// file as a single all-or-nothing operation. Each edit is checked against
// the content as it stands after every prior edit in the batch, so later
// edits can depend on earlier ones (e.g. renaming a symbol's definition,
// then a call site that only exists after the first edit) — but the
// moment any edit fails, the function returns without writing anything,
// so a batch that's wrong in step 4 of 5 can never leave the file
// half-edited.
func (t *Toolbox) multiEditFile(call ToolCallRequest) ToolResult {
relPath, _ := call.Arguments["path"].(string)
rawEdits, ok := call.Arguments["edits"].([]any)
if !ok || len(rawEdits) == 0 {
return ToolResult{Name: call.Name, Content: "multi_edit_file: \"edits\" must be a non-empty array", IsError: true}
}
type editSpec struct {
old, new string
replaceAll bool
}
edits := make([]editSpec, 0, len(rawEdits))
for i, raw := range rawEdits {
m, ok := raw.(map[string]any)
if !ok {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("multi_edit_file: edits[%d] must be an object", i), IsError: true}
}
oldStr, _ := m["old_string"].(string)
newStr, _ := m["new_string"].(string)
if oldStr == "" {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("multi_edit_file: edits[%d]: old_string must not be empty", i), IsError: true}
}
if oldStr == newStr {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("multi_edit_file: edits[%d]: old_string and new_string are identical — nothing to do", i), IsError: true}
}
edits = append(edits, editSpec{old: oldStr, new: newStr, replaceAll: argBool(m, "replace_all")})
}
full, err := t.resolvePath(relPath)
if err != nil {
return ToolResult{Name: call.Name, Content: err.Error(), IsError: true}
}
data, err := os.ReadFile(full)
if err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("multi_edit_file: %v", err), IsError: true}
}
content := string(data)
var allHunks []string
totalReplacements := 0
for i, e := range edits {
count := strings.Count(content, e.old)
switch {
case count == 0:
return ToolResult{Name: call.Name, Content: fmt.Sprintf(
"multi_edit_file: edits[%d]: old_string not found (after applying edits[0:%d]) — no changes were written to %s; the whole batch is atomic, so fix this edit and retry from the top", i, i, relPath), IsError: true}
case count > 1 && !e.replaceAll:
return ToolResult{Name: call.Name, Content: fmt.Sprintf(
"multi_edit_file: edits[%d]: old_string appears %d times (after applying edits[0:%d]), so it's ambiguous — no changes were written to %s; add context or set this edit's replace_all", i, count, i, relPath), IsError: true}
}
updated, hunks := applyEditWithDiff(content, e.old, e.new, e.replaceAll)
allHunks = append(allHunks, hunks...)
content = updated
if e.replaceAll {
totalReplacements += count
} else {
totalReplacements++
}
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("multi_edit_file: write: %v", err), IsError: true}
}
header := fmt.Sprintf("edited %s: applied %d edit(s), %d replacement(s)\n\n", relPath, len(edits), totalReplacements)
return ToolResult{Name: call.Name, Content: header + renderDiffHunks(allHunks)}
}
// applyEditWithDiff replaces old with new in content — every occurrence if
// replaceAll, otherwise the sole occurrence (callers must already have
// checked count/ambiguity themselves) — and returns the updated content
// alongside a unified-diff-style hunk for each replacement performed. This
// is shared by edit_file and multi_edit_file so both tools report changes
// the same way.
func applyEditWithDiff(content, oldStr, newStr string, replaceAll bool) (updated string, hunks []string) {
if !replaceAll {
idx := strings.Index(content, oldStr)
if idx < 0 { // shouldn't happen; callers check first, but stay safe
return content, nil
}
return content[:idx] + newStr + content[idx+len(oldStr):], []string{diffHunk(content, idx, oldStr, newStr)}
}
total := strings.Count(content, oldStr)
var sb strings.Builder
pos, shown := 0, 0
for {
idx := strings.Index(content[pos:], oldStr)
if idx < 0 {
break
}
abs := pos + idx
if shown < maxDiffHunksPerEdit {
hunks = append(hunks, diffHunk(content, abs, oldStr, newStr))
shown++
}
sb.WriteString(content[pos:abs])
sb.WriteString(newStr)
pos = abs + len(oldStr)
}
sb.WriteString(content[pos:])
if total > shown {
hunks = append(hunks, fmt.Sprintf("… %d further occurrence(s) replaced but not individually shown …", total-shown))
}
return sb.String(), hunks
}
// renderDiffHunks joins hunks with a small separator so multiple
// non-adjacent changes in one tool result read clearly as distinct
// hunks rather than one continuous block.
func renderDiffHunks(hunks []string) string {
if len(hunks) == 0 {
return ""
}
return strings.Join(hunks, " ⋮\n")
}
// diffHunk renders one unified-diff-style hunk for replacing old with new
// at byte offset `at` in content, expanded outward to whole lines (so a
// hunk never splits a line mid-way even though old/new themselves may
// start or end mid-line) and padded with up to diffContextLines lines of
// unchanged context on each side. It's built directly from the known
// old/new span — rather than a general line-diff algorithm — since
// edit_file's contract already guarantees the exact substring replaced.
func diffHunk(content string, at int, oldStr, newStr string) string {
lineStart, lineEnd := wholeLineRange(content, at, len(oldStr))
oldBlock := content[lineStart:lineEnd]
newBlock := oldBlock[:at-lineStart] + newStr + oldBlock[at-lineStart+len(oldStr):]
ctxStart := expandLinesBack(content, lineStart, diffContextLines)
ctxEnd := expandLinesForward(content, lineEnd, diffContextLines)
lineNo := strings.Count(content[:ctxStart], "\n") + 1
var sb strings.Builder
lineNo = writeDiffBlock(&sb, linesOf(content[ctxStart:lineStart]), " ", lineNo)
oldEnd := writeDiffBlock(&sb, linesOf(oldBlock), "-", lineNo)
writeDiffBlock(&sb, linesOf(newBlock), "+", lineNo)
writeDiffBlock(&sb, linesOf(content[lineEnd:ctxEnd]), " ", oldEnd)
return sb.String()
}
// wholeLineRange returns the [start,end) byte offsets of the smallest run
// of whole lines in content that fully contains the byte range
// [at, at+length), including the trailing newline of the last line (or
// running to EOF if there isn't one).
func wholeLineRange(content string, at, length int) (start, end int) {
start = strings.LastIndexByte(content[:at], '\n') + 1 // 0 if none found
end = at + length
if idx := strings.IndexByte(content[end:], '\n'); idx >= 0 {
end += idx + 1
} else {
end = len(content)
}
return start, end
}
func expandLinesBack(content string, pos, n int) int {
for i := 0; i < n && pos > 0; i++ {
pos = strings.LastIndexByte(content[:pos-1], '\n') + 1
}
return pos
}
func expandLinesForward(content string, pos, n int) int {
for i := 0; i < n && pos < len(content); i++ {
idx := strings.IndexByte(content[pos:], '\n')
if idx < 0 {
return len(content)
}
pos += idx + 1
}
return pos
}
// linesOf splits a whole-lines block (as produced by wholeLineRange) into
// its constituent lines, dropping the trailing empty element a final "\n"
// would otherwise produce.
func linesOf(s string) []string {
if s == "" {
return nil
}
return strings.Split(strings.TrimSuffix(s, "\n"), "\n")
}
// writeDiffBlock prints lines with a diff marker ("-", "+", or " ") and
// 1-based line numbers, eliding the middle of very long blocks (see
// maxHunkChangedLines) so a giant replacement doesn't flood the result.
// It returns the next line number after this block.
func writeDiffBlock(sb *strings.Builder, lines []string, marker string, lineNo int) int {
if len(lines) > maxHunkChangedLines {
half := maxHunkChangedLines / 2
for i := 0; i < half; i++ {
fmt.Fprintf(sb, "%6d %s %s\n", lineNo+i, marker, lines[i])
}
fmt.Fprintf(sb, " %s … %d lines omitted …\n", marker, len(lines)-maxHunkChangedLines)
for i := len(lines) - half; i < len(lines); i++ {
fmt.Fprintf(sb, "%6d %s %s\n", lineNo+i, marker, lines[i])
}
} else {
for i, l := range lines {
fmt.Fprintf(sb, "%6d %s %s\n", lineNo+i, marker, l)
}
}
return lineNo + len(lines)
}
func (t *Toolbox) listDirectory(call ToolCallRequest) ToolResult {
relPath, _ := call.Arguments["path"].(string)
if strings.TrimSpace(relPath) == "" {
relPath = "."
}
maxDepth, ok := argInt(call.Arguments, "max_depth")
if !ok || maxDepth <= 0 {
maxDepth = 3
}
root, err := t.resolvePath(relPath)
if err != nil {
return ToolResult{Name: call.Name, Content: err.Error(), IsError: true}
}
if info, err := os.Stat(root); err != nil || !info.IsDir() {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("list_directory: %q is not a directory in this workspace", relPath), IsError: true}
}
type entry struct {
rel string
isDir bool
}
var entries []entry
truncated := false
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil // skip unreadable entries rather than failing the whole listing
}
if path == root {
return nil
}
rel, _ := filepath.Rel(root, path)
depth := strings.Count(rel, string(filepath.Separator)) + 1
if d.IsDir() && ignoredDirNames[d.Name()] {
return filepath.SkipDir
}
if depth > maxDepth {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if len(entries) >= maxWalkEntries {
truncated = true
return filepath.SkipAll
}
entries = append(entries, entry{rel: filepath.ToSlash(rel), isDir: d.IsDir()})
return nil
})
if err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("list_directory: %v", err), IsError: true}
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].isDir != entries[j].isDir {
return entries[i].isDir // directories first
}
return entries[i].rel < entries[j].rel
})
var sb strings.Builder
fmt.Fprintf(&sb, "%s/\n", strings.TrimSuffix(relPath, "/"))
for _, e := range entries {
suffix := ""
if e.isDir {
suffix = "/"
}
fmt.Fprintf(&sb, " %s%s\n", e.rel, suffix)
}
if len(entries) == 0 {
sb.WriteString(" (empty)\n")
}
if truncated {
fmt.Fprintf(&sb, "\n… output truncated at %d entries; narrow `path` or `max_depth` for a fuller view of one area …\n", maxWalkEntries)
}
return ToolResult{Name: call.Name, Content: sb.String()}
}
// segmentToRegexp converts one path segment (no "/" in it) of a glob into
// regexp source, treating `*` as "within this segment" and `?` as a single
// rune, and escaping everything else.
func segmentToRegexp(seg string) string {
var sb strings.Builder
for _, r := range seg {
switch r {
case '*':
sb.WriteString("[^/]*")
case '?':
sb.WriteString("[^/]")
default:
sb.WriteString(regexp.QuoteMeta(string(r)))
}
}
return sb.String()
}
// globToRegexp compiles a shell-style glob into an anchored regexp over
// forward-slash-separated relative paths. `*` matches within one path
// segment; `?` matches a single rune within a segment; a `**` segment
// matches zero or more whole path segments, so "**/*.go" matches both
// "main.go" at the root and "internal/llm/tools.go" — the standard
// "globstar" behavior, which a naive character-by-character translation
// (treating `**` as ".*" in place) gets wrong for the common leading-`**/`
// case, since it would then require a literal "/" for a root-level match.
func globToRegexp(pattern string) (*regexp.Regexp, error) {
pattern = filepath.ToSlash(strings.TrimPrefix(pattern, "./"))
segments := strings.Split(pattern, "/")
var sb strings.Builder
sb.WriteString("^")
for i, seg := range segments {
last := i == len(segments)-1
if seg == "**" {
if last {
sb.WriteString(".*") // trailing "**" matches everything under here, any depth
} else {
sb.WriteString(`(?:[^/]+/)*`) // zero or more whole segments, each including its "/"
}
continue
}
sb.WriteString(segmentToRegexp(seg))
if !last {
sb.WriteString("/")
}
}
sb.WriteString("$")
return regexp.Compile(sb.String())
}
func (t *Toolbox) globSearch(call ToolCallRequest) ToolResult {
pattern, _ := call.Arguments["pattern"].(string)
if strings.TrimSpace(pattern) == "" {
return ToolResult{Name: call.Name, Content: "glob_search: empty pattern", IsError: true}
}
re, err := globToRegexp(pattern)
if err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("glob_search: invalid pattern: %v", err), IsError: true}
}
var matches []string
truncated := false
err = filepath.WalkDir(t.WorkspaceRoot, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if path == t.WorkspaceRoot {
return nil
}
if d.IsDir() {
if ignoredDirNames[d.Name()] {
return filepath.SkipDir
}
return nil
}
rel, _ := filepath.Rel(t.WorkspaceRoot, path)
rel = filepath.ToSlash(rel)
if !re.MatchString(rel) {
return nil
}
if len(matches) >= maxWalkEntries {
truncated = true
return filepath.SkipAll
}
matches = append(matches, rel)
return nil
})
if err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("glob_search: %v", err), IsError: true}
}
sort.Strings(matches)
if len(matches) == 0 {
return ToolResult{Name: call.Name, Content: "no files matched that pattern"}
}
out := strings.Join(matches, "\n")
if truncated {
out += fmt.Sprintf("\n\n… truncated at %d matches; narrow the pattern for a fuller view …", maxWalkEntries)
}
return ToolResult{Name: call.Name, Content: out}
}
func (t *Toolbox) grep(call ToolCallRequest) ToolResult {
pattern, _ := call.Arguments["pattern"].(string)
if strings.TrimSpace(pattern) == "" {
return ToolResult{Name: call.Name, Content: "grep: empty pattern", IsError: true}
}
if argBool(call.Arguments, "case_insensitive") && !strings.HasPrefix(pattern, "(?i)") {
pattern = "(?i)" + pattern
}
re, err := regexp.Compile(pattern)
if err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("grep: invalid regular expression: %v", err), IsError: true}
}
var pathRe *regexp.Regexp
if pathGlob, _ := call.Arguments["path_glob"].(string); strings.TrimSpace(pathGlob) != "" {
pathRe, err = globToRegexp(pathGlob)
if err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("grep: invalid path_glob: %v", err), IsError: true}
}
}
var sb strings.Builder
matchCount := 0
stop := false
walkErr := filepath.WalkDir(t.WorkspaceRoot, func(path string, d fs.DirEntry, err error) error {
if err != nil || stop {
return nil
}
if d.IsDir() {
if path != t.WorkspaceRoot && ignoredDirNames[d.Name()] {
return filepath.SkipDir
}
return nil
}
rel, _ := filepath.Rel(t.WorkspaceRoot, path)
rel = filepath.ToSlash(rel)
if pathRe != nil && !pathRe.MatchString(rel) {
return nil
}
info, err := d.Info()
if err != nil || info.Size() > maxGrepFileSize || info.Size() == 0 {
return nil
}
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
lineNo := 0
for scanner.Scan() {
lineNo++
line := scanner.Text()
if lineNo == 1 && strings.ContainsRune(line, 0) {
break // looks binary (NUL byte on the first line) — skip it
}
if re.MatchString(line) {
trimmed := strings.TrimSpace(line)
if len(trimmed) > 300 {
trimmed = trimmed[:300] + "…"
}
fmt.Fprintf(&sb, "%s:%d: %s\n", rel, lineNo, trimmed)
matchCount++
if matchCount >= maxGrepMatches {
stop = true
return filepath.SkipAll
}
}
}
return nil
})
if walkErr != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("grep: %v", walkErr), IsError: true}
}
if matchCount == 0 {
return ToolResult{Name: call.Name, Content: "no matches found"}
}
out := sb.String()
if stop {
out += fmt.Sprintf("\n… truncated at %d matches; narrow `pattern` or `path_glob` …\n", maxGrepMatches)
}
return ToolResult{Name: call.Name, Content: out}
}
// writeTodos doesn't touch the filesystem at all — it just validates and
// reformats the model's plan into a checklist. The tool call + result is
// persisted to the messages/tool_calls tables exactly like any other tool
// (see internal/handlers' chat flow), which is what makes the plan visible
// to the user as it updates, and gives the model a compact record of its
// own plan to refer back to on later rounds.

View File

@@ -0,0 +1,21 @@
package sandboxfs
// ToolCallRequest and ToolResult are local equivalents of the
// monolith's internal/models.ToolCallRequest/ToolResult — kept as
// plain structs here (rather than importing a shared models package)
// so this package's only dependency is the Go standard library, the
// same isolation internal/llm/tools.go itself had from internal/db,
// internal/handlers, etc. The workspace gRPC server (see
// internal/interfaces/grpc) is what actually constructs/consumes
// these, translating to and from the generated workspacev1 proto
// types.
type ToolCallRequest struct {
Name string
Arguments map[string]any
}
type ToolResult struct {
Name string
Content string
IsError bool
}

View File

@@ -0,0 +1,189 @@
package grpc
import (
"context"
"io/fs"
"os"
"path/filepath"
"sort"
"google.golang.org/protobuf/types/known/timestamppb"
workspacev1 "enkly/gen/workspace/v1"
"enkly/pkg/grpcerr"
)
// ignoredWorkspaceDirNames mirrors sandboxfs's own ignoredDirNames
// (unexported there): dependency/build/VCS directories that are noise
// in a file *listing* the same way they're noise for the agent's own
// list_directory/glob_search tools. Display-only — nothing security
// sensitive depends on it, since every path below still goes through
// Toolbox.ResolvePath regardless of what Tree skips.
var ignoredWorkspaceDirNames = map[string]bool{
".git": true, "node_modules": true, "vendor": true, ".venv": true,
"venv": true, "__pycache__": true, "dist": true, "build": true,
".idea": true, ".vscode": true, "target": true, ".next": true,
}
// maxWorkspaceFilesListed caps how many files Tree returns, mirroring
// internal/handlers/files.go's identical cap for the same reason: a
// workspace with an enormous tree shouldn't produce an enormous
// sidebar (or an enormous gRPC response).
const maxWorkspaceFilesListed = 500
// UploadFile replaces Handlers.saveUploadedFile. The gateway has
// already done every HTTP-specific part (multipart parsing, per-file
// size limits, CSRF, stripping any directory component the browser
// supplied) — see services/gateway/internal/handlers/files.go — and
// hands this RPC just a clean relative destination path and the bytes.
func (s *Server) UploadFile(ctx context.Context, req *workspacev1.UploadFileRequest) (*workspacev1.UploadFileResponse, error) {
full, err := s.Toolbox.ResolvePath(req.GetPath())
if err != nil {
return nil, grpcerr.InvalidArgument(err.Error())
}
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
return nil, grpcerr.Internal(err)
}
if err := os.WriteFile(full, req.GetContent(), 0o644); err != nil {
return nil, grpcerr.Internal(err)
}
return &workspacev1.UploadFileResponse{BytesWritten: int64(len(req.GetContent()))}, nil
}
// DownloadFile replaces Handlers.DownloadFile's file-reading half (the
// gateway still owns the actual HTTP streaming/Content-Disposition
// response).
func (s *Server) DownloadFile(ctx context.Context, req *workspacev1.DownloadFileRequest) (*workspacev1.DownloadFileResponse, error) {
full, err := s.Toolbox.ResolvePath(req.GetPath())
if err != nil {
return nil, grpcerr.NotFound("file")
}
info, err := os.Stat(full)
if err != nil || info.IsDir() {
return nil, grpcerr.NotFound("file")
}
data, err := os.ReadFile(full)
if err != nil {
return nil, grpcerr.Internal(err)
}
return &workspacev1.DownloadFileResponse{Content: data, Filename: filepath.Base(full)}, nil
}
// Tree replaces Handlers.listWorkspaceFiles (one_level = false, the
// default) AND the directory-listing half of Handlers.FileTree
// (one_level = true) — see TreeRequest's doc comment in the proto.
func (s *Server) Tree(ctx context.Context, req *workspacev1.TreeRequest) (*workspacev1.TreeResponse, error) {
if req.GetOneLevel() {
return s.treeOneLevel(req.GetPath())
}
return s.treeRecursive()
}
func (s *Server) treeOneLevel(relDir string) (*workspacev1.TreeResponse, error) {
full, err := s.Toolbox.ResolvePath(relDir)
if err != nil {
return nil, grpcerr.InvalidArgument(err.Error())
}
info, err := os.Stat(full)
if err != nil || !info.IsDir() {
return nil, grpcerr.NotFound("directory")
}
entries, err := os.ReadDir(full)
if err != nil {
return nil, grpcerr.Internal(err)
}
var nodes []*workspacev1.TreeNode
for _, e := range entries {
if e.Type()&fs.ModeSymlink != 0 {
continue
}
name := e.Name()
if e.IsDir() && ignoredWorkspaceDirNames[name] {
continue
}
childRel := name
if relDir != "" {
childRel = relDir + "/" + name
}
node := &workspacev1.TreeNode{Name: name, RelPath: childRel, IsDir: e.IsDir()}
if !e.IsDir() {
if fi, err := e.Info(); err == nil {
node.Size = fi.Size()
node.ModifiedAt = timestamppb.New(fi.ModTime())
}
}
nodes = append(nodes, node)
}
// Directories first, then files, each alphabetical — matches
// readTreeChildren's ordering exactly.
sort.SliceStable(nodes, func(i, j int) bool {
if nodes[i].IsDir != nodes[j].IsDir {
return nodes[i].IsDir
}
return nodes[i].Name < nodes[j].Name
})
if len(nodes) > maxWorkspaceFilesListed {
nodes = nodes[:maxWorkspaceFilesListed]
}
return &workspacev1.TreeResponse{Nodes: nodes}, nil
}
func (s *Server) treeRecursive() (*workspacev1.TreeResponse, error) {
root := s.Toolbox.WorkspaceRoot
var nodes []*workspacev1.TreeNode
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return nil // skip unreadable entries rather than failing the whole listing
}
if p == root {
return nil
}
if d.IsDir() {
if ignoredWorkspaceDirNames[d.Name()] {
return filepath.SkipDir
}
return nil
}
rel, relErr := filepath.Rel(root, p)
if relErr != nil {
return nil
}
info, infoErr := d.Info()
if infoErr != nil {
return nil
}
rel = filepath.ToSlash(rel)
nodes = append(nodes, &workspacev1.TreeNode{
Name: d.Name(), RelPath: rel, IsDir: false, Size: info.Size(),
ModifiedAt: timestamppb.New(info.ModTime()),
})
if len(nodes) >= maxWorkspaceFilesListed {
return filepath.SkipAll
}
return nil
})
if err != nil {
return nil, grpcerr.Internal(err)
}
sort.Slice(nodes, func(i, j int) bool { return nodes[i].RelPath < nodes[j].RelPath })
return &workspacev1.TreeResponse{Nodes: nodes}, nil
}
// StatFile replaces the Agent.Toolbox.ResolvePath + os.Stat pairing
// internal/handlers/deliverables.go used to decide whether a tool call
// "produced" a downloadable file.
func (s *Server) StatFile(ctx context.Context, req *workspacev1.StatFileRequest) (*workspacev1.StatFileResponse, error) {
full, err := s.Toolbox.ResolvePath(req.GetPath())
if err != nil {
return &workspacev1.StatFileResponse{Exists: false}, nil
}
info, err := os.Stat(full)
if err != nil {
return &workspacev1.StatFileResponse{Exists: false}, nil
}
return &workspacev1.StatFileResponse{
Exists: true, IsDir: info.IsDir(), Size: info.Size(),
ModifiedAt: timestamppb.New(info.ModTime()),
}, nil
}

View File

@@ -0,0 +1,97 @@
// Package grpc adapts sandboxfs.Toolbox onto the generated
// workspacev1.WorkspaceServiceServer interface. Every RPC here either
// replaces one agent tool (internal/llm/tools.go) or one human-facing
// files-panel endpoint (internal/handlers/files.go) from the monolith —
// see each method's doc comment.
package grpc
import (
"context"
workspacev1 "enkly/gen/workspace/v1"
"enkly/services/workspace/internal/infrastructure/sandboxfs"
)
type Server struct {
workspacev1.UnimplementedWorkspaceServiceServer
Toolbox *sandboxfs.Toolbox
}
func New(tb *sandboxfs.Toolbox) *Server { return &Server{Toolbox: tb} }
func (s *Server) ReadFile(ctx context.Context, req *workspacev1.ReadFileRequest) (*workspacev1.ReadFileResponse, error) {
res := s.Toolbox.ReadFile(req.GetPath(), int(req.GetOffset()), int(req.GetLimit()))
if res.IsError {
return &workspacev1.ReadFileResponse{IsError: true, ErrorMessage: res.Content}, nil
}
return &workspacev1.ReadFileResponse{Content: res.Content}, nil
}
func (s *Server) WriteFile(ctx context.Context, req *workspacev1.WriteFileRequest) (*workspacev1.WriteFileResponse, error) {
res := s.Toolbox.WriteFile(req.GetPath(), req.GetContent())
if res.IsError {
return &workspacev1.WriteFileResponse{IsError: true, ErrorMessage: res.Content}, nil
}
return &workspacev1.WriteFileResponse{BytesWritten: int64(len(req.GetContent()))}, nil
}
func (s *Server) EditFile(ctx context.Context, req *workspacev1.EditFileRequest) (*workspacev1.EditFileResponse, error) {
res := s.Toolbox.EditFile(req.GetPath(), req.GetOldString(), req.GetNewString(), req.GetReplaceAll())
if res.IsError {
return &workspacev1.EditFileResponse{IsError: true, ErrorMessage: res.Content}, nil
}
return &workspacev1.EditFileResponse{DiffPreview: res.Content}, nil
}
func (s *Server) MultiEditFile(ctx context.Context, req *workspacev1.MultiEditFileRequest) (*workspacev1.MultiEditFileResponse, error) {
edits := make([]sandboxfs.WorkspaceEdit, 0, len(req.GetEdits()))
for _, e := range req.GetEdits() {
edits = append(edits, sandboxfs.WorkspaceEdit{
OldString: e.GetOldString(), NewString: e.GetNewString(), ReplaceAll: e.GetReplaceAll(),
})
}
res := s.Toolbox.MultiEditFile(req.GetPath(), edits)
if res.IsError {
return &workspacev1.MultiEditFileResponse{IsError: true, ErrorMessage: res.Content}, nil
}
return &workspacev1.MultiEditFileResponse{DiffPreview: res.Content}, nil
}
func (s *Server) ListDirectory(ctx context.Context, req *workspacev1.ListDirectoryRequest) (*workspacev1.ListDirectoryResponse, error) {
res := s.Toolbox.ListDirectory(req.GetPath())
if res.IsError {
return &workspacev1.ListDirectoryResponse{IsError: true, ErrorMessage: res.Content}, nil
}
return &workspacev1.ListDirectoryResponse{Listing: res.Content}, nil
}
func (s *Server) GlobSearch(ctx context.Context, req *workspacev1.GlobSearchRequest) (*workspacev1.GlobSearchResponse, error) {
res := s.Toolbox.GlobSearch(req.GetPattern(), req.GetPath())
if res.IsError {
return &workspacev1.GlobSearchResponse{IsError: true, ErrorMessage: res.Content}, nil
}
return &workspacev1.GlobSearchResponse{Output: res.Content}, nil
}
func (s *Server) Grep(ctx context.Context, req *workspacev1.GrepRequest) (*workspacev1.GrepResponse, error) {
res := s.Toolbox.Grep(req.GetPattern(), req.GetPath(), req.GetGlob(), req.GetCaseInsensitive())
if res.IsError {
return &workspacev1.GrepResponse{IsError: true, ErrorMessage: res.Content}, nil
}
return &workspacev1.GrepResponse{Output: res.Content}, nil
}
// RunBashCommand replaces the run_bash_command tool. req.TimeoutSeconds
// is accepted but not applied per-call — the monolith's sandbox only
// ever used one process-wide timeout (ENKLY_BASH_TIMEOUT_SECONDS /
// Toolbox.CommandTimeout, set once at startup; see cmd/server/main.go),
// and a per-call override wasn't a feature of the original tool, so
// introducing one here would be a behavior change, not a neutral
// restructuring.
func (s *Server) RunBashCommand(ctx context.Context, req *workspacev1.RunBashCommandRequest) (*workspacev1.RunBashCommandResponse, error) {
res := s.Toolbox.RunBashCommand(ctx, req.GetCommand())
if res.IsError {
return &workspacev1.RunBashCommandResponse{IsError: true, ErrorMessage: res.Content}, nil
}
return &workspacev1.RunBashCommandResponse{Output: res.Content}, nil
}