This commit is contained in:
23
services/workspace/Dockerfile
Normal file
23
services/workspace/Dockerfile
Normal 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"]
|
||||
96
services/workspace/cmd/server/main.go
Normal file
96
services/workspace/cmd/server/main.go
Normal 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()
|
||||
}
|
||||
@@ -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,
|
||||
}})
|
||||
}
|
||||
437
services/workspace/internal/infrastructure/sandboxfs/sandbox.go
Normal file
437
services/workspace/internal/infrastructure/sandboxfs/sandbox.go
Normal 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}
|
||||
}
|
||||
764
services/workspace/internal/infrastructure/sandboxfs/tools.go
Normal file
764
services/workspace/internal/infrastructure/sandboxfs/tools.go
Normal 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.
|
||||
@@ -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
|
||||
}
|
||||
189
services/workspace/internal/interfaces/grpc/files.go
Normal file
189
services/workspace/internal/interfaces/grpc/files.go
Normal 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
|
||||
}
|
||||
97
services/workspace/internal/interfaces/grpc/server.go
Normal file
97
services/workspace/internal/interfaces/grpc/server.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user