From 6e6235b7a9fcfd49edd45c1bf79699bcd7486246 Mon Sep 17 00:00:00 2001 From: Yossi Eliaz Date: Tue, 15 Sep 2026 21:15:12 +0300 Subject: [PATCH 1/8] noop --- __nonexistent__ | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 __nonexistent__ diff --git a/__nonexistent__ b/__nonexistent__ new file mode 100644 index 0000000000..e69de29bb2 From 47cb9f887b211e5943ba5f78ea36ddaa245df175 Mon Sep 17 00:00:00 2001 From: Yossi Eliaz Date: Tue, 15 Sep 2026 21:15:30 +0300 Subject: [PATCH 2/8] chore: remove accidental placeholder --- __nonexistent__ | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 __nonexistent__ diff --git a/__nonexistent__ b/__nonexistent__ deleted file mode 100644 index e69de29bb2..0000000000 From cff04573a616b2e9d01b8c106ce30a8e44a7ad29 Mon Sep 17 00:00:00 2001 From: Yossi Eliaz Date: Tue, 15 Sep 2026 21:22:14 +0300 Subject: [PATCH 3/8] feat(boxenv): add Ascii Box placement provider --- internal/adapter/boxenv/boxenv.go | 1 + 1 file changed, 1 insertion(+) create mode 100644 internal/adapter/boxenv/boxenv.go diff --git a/internal/adapter/boxenv/boxenv.go b/internal/adapter/boxenv/boxenv.go new file mode 100644 index 0000000000..311c8dd065 --- /dev/null +++ b/internal/adapter/boxenv/boxenv.go @@ -0,0 +1 @@ +PLACEHOLDER \ No newline at end of file From 911073b74651ae7bd07fcc303e259a9b16e25a6d Mon Sep 17 00:00:00 2001 From: Yossi Eliaz Date: Tue, 15 Sep 2026 21:23:05 +0300 Subject: [PATCH 4/8] feat(boxenv): implement Box placement lifecycle --- internal/adapter/boxenv/boxenv.go | 237 +++++++++++++++++++++++++++++- 1 file changed, 236 insertions(+), 1 deletion(-) diff --git a/internal/adapter/boxenv/boxenv.go b/internal/adapter/boxenv/boxenv.go index 311c8dd065..1175589a5b 100644 --- a/internal/adapter/boxenv/boxenv.go +++ b/internal/adapter/boxenv/boxenv.go @@ -1 +1,236 @@ -PLACEHOLDER \ No newline at end of file +// Package boxenv implements a Mecatl placement provider backed by Ascii Box. +package boxenv + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/stacklok/mecatl/engine/adapter/memledger" + "github.com/stacklok/mecatl/engine/adapter/nofs" + "github.com/stacklok/mecatl/engine/session" + "github.com/stacklok/mecatl/engine/tool" + "github.com/stacklok/mecatl/internal/adapter/server" +) + +const ( + // Kind is the durable EnvironmentKind minted for Ascii Box placements. + Kind session.EnvironmentKind = "box" + + defaultBaseURL = "https://ascii.dev/api/box/v1" + providerVersion = "box-api-v1" + workspaceRoot = "/workspace" + boxWorkspace = "workspace" + defaultTTL = 3600 + defaultReady = 5 * time.Minute + defaultPoll = 2 * time.Second + maxResponseBody = 8 << 20 +) + +var ( + // ErrInvalidConfig reports an invalid trusted operator configuration. + ErrInvalidConfig = errors.New("boxenv: invalid config") + // ErrPathEscape reports a path that is not confined to the Box workspace. + ErrPathEscape = errors.New("boxenv: path escapes workspace root") +) + +// Config configures an Ascii Box placement provider. APIKey is never persisted +// in an EnvironmentRef or forwarded into a Box. By default Boxes are created +// with noEnv=true so account credentials and secrets stay outside the sandbox. +type Config struct { + APIKey string + BaseURL string + Scope server.PlacementScope + TTLSeconds int + InheritEnvironment bool + HTTPClient *http.Client + ReadyTimeout time.Duration + PollInterval time.Duration +} + +// Provider implements server.PlacementProvider and server.PlacementReattacher. +type Provider struct { + client *apiClient + scope server.PlacementScope + ttlSeconds int + inheritEnvironment bool + readyTimeout time.Duration + pollInterval time.Duration +} + +var _ server.PlacementProvider = (*Provider)(nil) +var _ server.PlacementReattacher = (*Provider)(nil) + +// NewProvider constructs a Box placement provider from trusted operator config. +func NewProvider(cfg Config) (*Provider, error) { + if strings.TrimSpace(cfg.APIKey) == "" || cfg.Scope == "" { + return nil, fmt.Errorf("%w: APIKey and Scope are required", ErrInvalidConfig) + } + baseURL := strings.TrimSpace(cfg.BaseURL) + if baseURL == "" { + baseURL = defaultBaseURL + } + parsed, err := url.Parse(baseURL) + if err != nil || parsed.Host == "" || parsed.Scheme == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return nil, fmt.Errorf("%w: invalid BaseURL", ErrInvalidConfig) + } + if parsed.Scheme != "https" && !(parsed.Scheme == "http" && isLoopbackHost(parsed.Hostname())) { + return nil, fmt.Errorf("%w: BaseURL must use https (http is allowed only for loopback tests)", ErrInvalidConfig) + } + parsed.Path = strings.TrimSuffix(parsed.Path, "/") + + ttl := cfg.TTLSeconds + if ttl <= 0 { + ttl = defaultTTL + } + ready := cfg.ReadyTimeout + if ready <= 0 { + ready = defaultReady + } + poll := cfg.PollInterval + if poll <= 0 { + poll = defaultPoll + } + httpClient := cfg.HTTPClient + if httpClient == nil { + httpClient = &http.Client{Timeout: 70 * time.Second} + } + return &Provider{ + client: &apiClient{base: parsed, apiKey: cfg.APIKey, http: httpClient}, + scope: cfg.Scope, + ttlSeconds: ttl, + inheritEnvironment: cfg.InheritEnvironment, + readyTimeout: ready, + pollInterval: poll, + }, nil +} + +// Bind provisions the deployment default or returns the no-filesystem attenuation. +// Worktree selectors are intentionally not fabricated: Box-native fork/merge can +// be added only when Mecatl has an explicit remote worktree selection contract. +func (p *Provider) Bind(ctx context.Context, req server.PlacementBindRequest) (server.PlacementBinding, error) { + if p == nil || p.client == nil || req.Scope != p.scope { + return server.PlacementBinding{}, server.ErrPlacementNotFound + } + switch { + case req.Selector.IsNoFS(): + return noFSBinding() + case req.Selector.IsDefault(): + box, err := p.client.createBox(ctx, createBoxRequest{ + TTLSeconds: p.ttlSeconds, + NoEnv: !p.inheritEnvironment, + }) + if err != nil { + return server.PlacementBinding{}, fmt.Errorf("%w: create Box: %v", server.ErrPlacementUnavailable, err) + } + if err := p.prepare(ctx, box.ID); err != nil { + _ = p.client.stopBox(context.WithoutCancel(ctx), box.ID) + return server.PlacementBinding{}, fmt.Errorf("%w: prepare Box: %v", server.ErrPlacementUnavailable, err) + } + return p.binding(box.ID), nil + default: + return server.PlacementBinding{}, server.ErrPlacementNotFound + } +} + +// Reattach resumes and rebinds the exact persisted Box identity. Unknown, +// stale, or mismatched refs fail loudly; this never creates a replacement Box. +func (p *Provider) Reattach(ctx context.Context, req server.PlacementReattachRequest) (server.PlacementBinding, error) { + if p == nil || p.client == nil || req.Scope != p.scope { + return server.PlacementBinding{}, server.ErrPlacementNotFound + } + if req.Ref == noFSRef() { + return noFSBinding() + } + if req.Ref.Kind != Kind || req.Ref.Revision != providerVersion || req.Ref.ID == "" { + return server.PlacementBinding{}, server.ErrPlacementNotFound + } + box, err := p.client.getBox(ctx, req.Ref.ID) + if err != nil { + if isStatus(err, http.StatusNotFound) { + return server.PlacementBinding{}, server.ErrPlacementNotFound + } + return server.PlacementBinding{}, fmt.Errorf("%w: get Box: %v", server.ErrPlacementUnavailable, err) + } + if strings.EqualFold(box.State, "stopped") || strings.EqualFold(box.State, "archived") { + if err := p.client.resumeBox(ctx, box.ID); err != nil { + return server.PlacementBinding{}, fmt.Errorf("%w: resume Box: %v", server.ErrPlacementUnavailable, err) + } + } + if err := p.prepare(ctx, box.ID); err != nil { + return server.PlacementBinding{}, fmt.Errorf("%w: prepare Box: %v", server.ErrPlacementUnavailable, err) + } + return p.binding(box.ID), nil +} + +func (p *Provider) prepare(ctx context.Context, id string) error { + if _, err := p.waitReady(ctx, id); err != nil { + return err + } + // Box file APIs address "workspace/..." while Mecatl reports /workspace. + // Make those two views the same namespace for Shell as well. + const command = `mkdir -p workspace; if [ ! -L /workspace ] && ! findmnt -rn --target /workspace >/dev/null 2>&1; then sudo rmdir /workspace 2>/dev/null || true; fi; if [ ! -e /workspace ]; then sudo ln -s "$PWD/workspace" /workspace 2>/dev/null || true; fi; if [ ! -L /workspace ] && ! findmnt -rn --target /workspace >/dev/null 2>&1; then sudo mkdir -p /workspace 2>/dev/null && sudo mount --bind "$PWD/workspace" /workspace 2>/dev/null || true; fi; touch /workspace/.mecatl-box-check && test -e workspace/.mecatl-box-check && rm -f /workspace/.mecatl-box-check workspace/.mecatl-box-check` + result, err := p.client.command(ctx, id, commandRequest{Command: command, TimeoutSeconds: 10}) + if err != nil { + return err + } + if result.ExitCode != 0 { + return fmt.Errorf("workspace initialization exited %d: %s", result.ExitCode, bounded(strings.TrimSpace(result.Stderr), 256)) + } + return nil +} + +func (p *Provider) waitReady(ctx context.Context, id string) (boxInfo, error) { + deadline := time.Now().Add(p.readyTimeout) + for { + box, err := p.client.getBox(ctx, id) + if err != nil { + return boxInfo{}, err + } + switch strings.ToLower(box.State) { + case "ready", "idle", "running": + return box, nil + case "error", "failed", "deleted": + return boxInfo{}, fmt.Errorf("Box %s entered state %q", id, box.State) + } + if time.Now().After(deadline) { + return boxInfo{}, fmt.Errorf("timed out waiting for Box %s; last state %q", id, box.State) + } + timer := time.NewTimer(p.pollInterval) + select { + case <-ctx.Done(): + timer.Stop() + return boxInfo{}, ctx.Err() + case <-timer.C: + } + } +} + +func (p *Provider) binding(id string) server.PlacementBinding { + ref := session.EnvironmentRef{Kind: Kind, ID: id, Revision: providerVersion} + ws := &Workspace{client: p.client, boxID: id} + runner := &Runner{client: p.client, boxID: id} + env := tool.MustEnvironment(ref, ws, memledger.New(), runner) + return server.PlacementBinding{ + Environment: env, + Ref: ref, + Metadata: server.PlacementMetadata{Kind: string(Kind), Label: "Ascii Box", Revision: providerVersion}, + } +} + +func noFSRef() session.EnvironmentRef { + return session.EnvironmentRef{Kind: session.EnvKindNoFS, ID: "no-fs", Revision: "nofs-v1"} +} + +func noFSBinding() (server.PlacementBinding, error) { + ref := noFSRef() + env, err := tool.NewEnvironment(ref, nofs.New(), memledger.New(), nil) + if err != nil { + return server.PlacementBinding{}, server.ErrPlacementUnavailable + } + return server.PlacementBinding{Environment: env, Ref: ref, Metadata: server.PlacementMetadata{Label: "No filesystem"}}, nil +} From 6228b156d4354bc34e263e041ed429302c91f685 Mon Sep 17 00:00:00 2001 From: Yossi Eliaz Date: Tue, 15 Sep 2026 21:23:27 +0300 Subject: [PATCH 5/8] feat(boxenv): add remote workspace and command runner --- internal/adapter/boxenv/workspace.go | 298 +++++++++++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 internal/adapter/boxenv/workspace.go diff --git a/internal/adapter/boxenv/workspace.go b/internal/adapter/boxenv/workspace.go new file mode 100644 index 0000000000..f9c4bf26d5 --- /dev/null +++ b/internal/adapter/boxenv/workspace.go @@ -0,0 +1,298 @@ +package boxenv + +import ( + "bufio" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io/fs" + "path" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/bmatcuk/doublestar/v4" + + "github.com/stacklok/mecatl/engine/tool" +) + +// Workspace is a path-confined Box file workspace. Mutation CAS is serialized +// for one live Workspace handle and versioned with SHA-256, matching the core +// Workspace contract. Shell or another independently reattached handle remains +// a non-cooperating writer, the same residual documented by tool.Workspace. +type Workspace struct { + client *apiClient + boxID string + mu sync.Mutex +} + +var _ tool.Workspace = (*Workspace)(nil) + +func (w *Workspace) Root() string { return workspaceRoot } + +func (w *Workspace) Read(ctx context.Context, p string) ([]byte, error) { + key, err := cleanPath(p) + if err != nil { + return nil, err + } + return w.client.readFile(ctx, w.boxID, boxPath(key)) +} + +func (w *Workspace) ReadVersion(ctx context.Context, p string) ([]byte, tool.FileVersion, error) { + data, err := w.Read(ctx, p) + if err != nil { + return nil, tool.FileVersion{}, err + } + return data, versionOf(data), nil +} + +func (w *Workspace) CreateFile(ctx context.Context, p string, data []byte) (tool.FileVersion, error) { + key, err := cleanPath(p) + if err != nil { + return tool.FileVersion{}, err + } + w.mu.Lock() + defer w.mu.Unlock() + if _, err := w.client.readFile(ctx, w.boxID, boxPath(key)); err == nil { + return tool.FileVersion{}, &fs.PathError{Op: "create", Path: p, Err: fs.ErrExist} + } else if !errors.Is(err, fs.ErrNotExist) { + return tool.FileVersion{}, err + } + if err := w.ensureParent(ctx, key); err != nil { + return tool.FileVersion{}, err + } + if err := w.client.writeFile(ctx, w.boxID, boxPath(key), data); err != nil { + return tool.FileVersion{}, err + } + return versionOf(data), nil +} + +func (w *Workspace) ensureParent(ctx context.Context, key string) error { + dir := path.Dir(key) + if dir == "." { + return nil + } + result, err := w.client.command(ctx, w.boxID, commandRequest{ + Command: "mkdir -p -- " + shellQuote(dir), + Cwd: boxWorkspace, + TimeoutSeconds: 10, + }) + if err != nil { + return err + } + if result.ExitCode != 0 { + return fmt.Errorf("boxenv: create parent directory exited %d", result.ExitCode) + } + return nil +} + +func (w *Workspace) ReplaceFile(ctx context.Context, p string, old tool.FileVersion, data []byte) (tool.FileVersion, error) { + key, err := cleanPath(p) + if err != nil { + return tool.FileVersion{}, err + } + w.mu.Lock() + defer w.mu.Unlock() + current, err := w.client.readFile(ctx, w.boxID, boxPath(key)) + if err != nil { + return tool.FileVersion{}, err + } + if !versionOf(current).Equal(old) { + return tool.FileVersion{}, &tool.VersionMismatchError{Path: p} + } + if err := w.client.writeFile(ctx, w.boxID, boxPath(key), data); err != nil { + return tool.FileVersion{}, err + } + return versionOf(data), nil +} + +func (w *Workspace) Stat(ctx context.Context, p string) (tool.FileInfo, error) { + key, err := cleanPath(p) + if err != nil { + return tool.FileInfo{}, err + } + q := shellQuote(key) + cmd := "if [ ! -e " + q + " ] && [ ! -L " + q + " ]; then exit 44; fi; if [ -d " + q + " ]; then printf 'd\\n'; else printf 'f\\n'; fi; stat -c '%a\\n%s\\n%Y' -- " + q + result, err := w.client.command(ctx, w.boxID, commandRequest{Command: cmd, Cwd: boxWorkspace, TimeoutSeconds: 10}) + if err != nil { + return tool.FileInfo{}, err + } + if result.ExitCode == 44 { + return tool.FileInfo{}, &fs.PathError{Op: "stat", Path: p, Err: fs.ErrNotExist} + } + if result.ExitCode != 0 { + return tool.FileInfo{}, fmt.Errorf("boxenv: stat %q exited %d", p, result.ExitCode) + } + lines := strings.Split(strings.TrimSpace(result.Stdout), "\n") + if len(lines) < 4 { + return tool.FileInfo{}, fmt.Errorf("boxenv: malformed stat response") + } + perm, err := strconv.ParseUint(lines[1], 8, 32) + if err != nil { + return tool.FileInfo{}, fmt.Errorf("boxenv: parse stat mode: %w", err) + } + size, err := strconv.ParseInt(lines[2], 10, 64) + if err != nil { + return tool.FileInfo{}, fmt.Errorf("boxenv: parse stat size: %w", err) + } + mtime, err := strconv.ParseInt(lines[3], 10, 64) + if err != nil { + return tool.FileInfo{}, fmt.Errorf("boxenv: parse stat mtime: %w", err) + } + isDir := lines[0] == "d" + mode := fs.FileMode(perm) + if isDir { + mode |= fs.ModeDir + } + return tool.FileInfo{Name: path.Base(key), Size: size, Mode: mode, ModTime: time.Unix(mtime, 0), IsDir: isDir}, nil +} + +func (w *Workspace) Glob(ctx context.Context, pattern string) ([]string, error) { + pat := normalizeGlobPattern(pattern) + if pat == "" { + return nil, nil + } + // Validate before doing remote I/O. + if _, err := doublestar.Match(pat, "probe"); err != nil { + return nil, err + } + result, err := w.client.command(ctx, w.boxID, commandRequest{Command: `find . -type f -print0`, Cwd: boxWorkspace, TimeoutSeconds: 30}) + if err != nil { + return nil, err + } + if result.ExitCode != 0 { + return nil, fmt.Errorf("boxenv: glob inventory exited %d", result.ExitCode) + } + var out []string + for _, raw := range strings.Split(result.Stdout, "\x00") { + name := strings.TrimPrefix(raw, "./") + if name == "" { + continue + } + ok, err := doublestar.Match(pat, name) + if err != nil { + return nil, err + } + if ok { + out = append(out, name) + } + } + sort.Strings(out) + return out, nil +} + +func (w *Workspace) Grep(ctx context.Context, pattern, pathGlob string) ([]tool.GrepMatch, error) { + re, err := regexp.Compile(pattern) + if err != nil { + return nil, err + } + if pathGlob == "" { + pathGlob = "**" + } + files, err := w.Glob(ctx, pathGlob) + if err != nil { + return nil, err + } + const maxMatches = 1000 + out := make([]tool.GrepMatch, 0) + for _, name := range files { + data, err := w.Read(ctx, name) + if err != nil { + return nil, err + } + scanner := bufio.NewScanner(bytes.NewReader(data)) + scanner.Buffer(make([]byte, 64*1024), 4<<20) + line := 0 + for scanner.Scan() { + line++ + text := scanner.Text() + if re.MatchString(text) { + out = append(out, tool.GrepMatch{Path: name, Line: line, Text: text}) + if len(out) >= maxMatches { + return out, nil + } + } + } + if err := scanner.Err(); err != nil { + return nil, err + } + } + return out, nil +} + +// Runner executes shell commands in the same Box workspace namespace as Workspace. +type Runner struct { + client *apiClient + boxID string +} + +var _ tool.CommandRunner = (*Runner)(nil) + +func (r *Runner) BoundWorkspaceRoot() string { return workspaceRoot } + +func (r *Runner) Run(ctx context.Context, command string) (tool.CommandResult, error) { + timeout := 60 + if deadline, ok := ctx.Deadline(); ok { + remaining := time.Until(deadline) + if remaining <= 0 { + return tool.CommandResult{}, ctx.Err() + } + timeout = int((remaining + time.Second - 1) / time.Second) + if timeout < 1 { + timeout = 1 + } + if timeout > 60 { + timeout = 60 + } + } + result, err := r.client.command(ctx, r.boxID, commandRequest{Command: command, Cwd: boxWorkspace, TimeoutSeconds: timeout}) + if err != nil { + return tool.CommandResult{}, err + } + return tool.CommandResult{Stdout: result.Stdout, Stderr: result.Stderr, ExitCode: result.ExitCode}, nil +} + +func cleanPath(p string) (string, error) { + if p == "" || strings.ContainsRune(p, '\x00') || strings.HasPrefix(p, "/") { + return "", fmt.Errorf("%w: %q", ErrPathEscape, p) + } + for _, part := range strings.Split(p, "/") { + if part == ".." { + return "", fmt.Errorf("%w: %q", ErrPathEscape, p) + } + } + cleaned := path.Clean(p) + if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") { + return "", fmt.Errorf("%w: %q", ErrPathEscape, p) + } + return cleaned, nil +} + +func boxPath(key string) string { return boxWorkspace + "/" + key } + +func versionOf(data []byte) tool.FileVersion { + sum := sha256.Sum256(data) + return tool.NewFileVersion(hex.EncodeToString(sum[:])) +} + +func normalizeGlobPattern(pattern string) string { + pat := strings.TrimPrefix(pattern, "/") + for strings.HasPrefix(pat, "./") { + pat = pat[2:] + } + pat = strings.TrimPrefix(pat, "/") + if pat == "." { + return "" + } + return pat +} + +func shellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" +} From 4ea9899d823e51ddee063d0f02996712b760445b Mon Sep 17 00:00:00 2001 From: Yossi Eliaz Date: Tue, 15 Sep 2026 21:23:46 +0300 Subject: [PATCH 6/8] feat(boxenv): add bounded Box API client --- internal/adapter/boxenv/client.go | 226 ++++++++++++++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 internal/adapter/boxenv/client.go diff --git a/internal/adapter/boxenv/client.go b/internal/adapter/boxenv/client.go new file mode 100644 index 0000000000..ae6e188064 --- /dev/null +++ b/internal/adapter/boxenv/client.go @@ -0,0 +1,226 @@ +package boxenv + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "net/url" + "strings" +) + +type apiClient struct { + base *url.URL + apiKey string + http *http.Client +} + +type boxInfo struct { + ID string `json:"id"` + State string `json:"state"` +} + +type createBoxRequest struct { + TTLSeconds int `json:"ttlSeconds"` + NoEnv bool `json:"noEnv"` +} + +type commandRequest struct { + Command string `json:"command"` + Cwd string `json:"cwd,omitempty"` + TimeoutSeconds int `json:"timeoutSeconds,omitempty"` +} + +type commandResult struct { + ExitCode int `json:"exitCode"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` +} + +type apiError struct { + status int + code string + message string +} + +func (e *apiError) Error() string { + if e.code != "" { + return fmt.Sprintf("box API: status %d (%s): %s", e.status, e.code, e.message) + } + return fmt.Sprintf("box API: status %d: %s", e.status, e.message) +} + +func isStatus(err error, status int) bool { + var apiErr *apiError + return errors.As(err, &apiErr) && apiErr.status == status +} + +func (c *apiClient) createBox(ctx context.Context, in createBoxRequest) (boxInfo, error) { + var out struct { + Box boxInfo `json:"box"` + } + if err := c.request(ctx, http.MethodPost, "/boxes", nil, in, &out); err != nil { + return boxInfo{}, err + } + if out.Box.ID == "" { + return boxInfo{}, errors.New("box API: create returned empty box id") + } + return out.Box, nil +} + +func (c *apiClient) getBox(ctx context.Context, id string) (boxInfo, error) { + var out struct { + Box boxInfo `json:"box"` + } + if err := c.request(ctx, http.MethodGet, "/boxes/"+url.PathEscape(id), nil, nil, &out); err != nil { + return boxInfo{}, err + } + if out.Box.ID == "" { + out.Box.ID = id + } + return out.Box, nil +} + +func (c *apiClient) stopBox(ctx context.Context, id string) error { + return c.request(ctx, http.MethodPost, "/boxes/"+url.PathEscape(id)+"/stop", nil, struct{}{}, nil) +} + +func (c *apiClient) resumeBox(ctx context.Context, id string) error { + return c.request(ctx, http.MethodPost, "/boxes/"+url.PathEscape(id)+"/resume", nil, struct{}{}, nil) +} + +func (c *apiClient) command(ctx context.Context, id string, in commandRequest) (commandResult, error) { + var out struct { + Result *commandResult `json:"result"` + ExitCode int `json:"exitCode"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + } + if err := c.request(ctx, http.MethodPost, "/boxes/"+url.PathEscape(id)+"/commands", nil, in, &out); err != nil { + return commandResult{}, err + } + if out.Result != nil { + return *out.Result, nil + } + return commandResult{ExitCode: out.ExitCode, Stdout: out.Stdout, Stderr: out.Stderr}, nil +} + +func (c *apiClient) readFile(ctx context.Context, id, filePath string) ([]byte, error) { + query := url.Values{"path": {filePath}, "encoding": {"base64"}} + var out struct { + Content *string `json:"content"` + File *struct { + Content string `json:"content"` + } `json:"file"` + } + if err := c.request(ctx, http.MethodGet, "/boxes/"+url.PathEscape(id)+"/files", query, nil, &out); err != nil { + if isStatus(err, http.StatusNotFound) { + return nil, &fs.PathError{Op: "read", Path: filePath, Err: fs.ErrNotExist} + } + return nil, err + } + var encoded string + switch { + case out.Content != nil: + encoded = *out.Content + case out.File != nil: + encoded = out.File.Content + default: + return nil, errors.New("box API: file response missing content") + } + data, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf("box API: decode file content: %w", err) + } + return data, nil +} + +func (c *apiClient) writeFile(ctx context.Context, id, filePath string, data []byte) error { + body := struct { + Path string `json:"path"` + Content string `json:"content"` + Encoding string `json:"encoding"` + }{Path: filePath, Content: base64.StdEncoding.EncodeToString(data), Encoding: "base64"} + return c.request(ctx, http.MethodPut, "/boxes/"+url.PathEscape(id)+"/files", nil, body, nil) +} + +func (c *apiClient) request(ctx context.Context, method, endpoint string, query url.Values, body any, out any) error { + u := *c.base + u.Path = strings.TrimSuffix(c.base.Path, "/") + endpoint + u.RawQuery = query.Encode() + var reader io.Reader + if body != nil { + payload, err := json.Marshal(body) + if err != nil { + return err + } + reader = bytes.NewReader(payload) + } + req, err := http.NewRequestWithContext(ctx, method, u.String(), reader) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + payload, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody+1)) + if err != nil { + return err + } + if len(payload) > maxResponseBody { + return errors.New("box API: response exceeds limit") + } + var apiBody struct { + OK *bool `json:"ok"` + Code string `json:"code"` + Message string `json:"message"` + Error *struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + _ = json.Unmarshal(payload, &apiBody) + if resp.StatusCode < 200 || resp.StatusCode >= 300 || (apiBody.OK != nil && !*apiBody.OK) { + if apiBody.Error != nil { + if apiBody.Code == "" { + apiBody.Code = apiBody.Error.Code + } + if apiBody.Message == "" { + apiBody.Message = apiBody.Error.Message + } + } + return &apiError{status: resp.StatusCode, code: bounded(apiBody.Code, 64), message: bounded(apiBody.Message, 256)} + } + if out == nil || len(bytes.TrimSpace(payload)) == 0 { + return nil + } + if err := json.Unmarshal(payload, out); err != nil { + return fmt.Errorf("box API: decode response: %w", err) + } + return nil +} + +func isLoopbackHost(host string) bool { + host = strings.Trim(strings.ToLower(host), "[]") + return host == "localhost" || host == "127.0.0.1" || host == "::1" +} + +func bounded(s string, n int) string { + r := []rune(strings.TrimSpace(s)) + if len(r) > n { + r = r[:n] + } + return string(r) +} From dd76a7b01a1d6d3aaf771058f60b376a2a6b0310 Mon Sep 17 00:00:00 2001 From: Yossi Eliaz Date: Tue, 15 Sep 2026 21:24:16 +0300 Subject: [PATCH 7/8] test(boxenv): cover placement lifecycle and workspace CAS --- internal/adapter/boxenv/boxenv_test.go | 300 +++++++++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 internal/adapter/boxenv/boxenv_test.go diff --git a/internal/adapter/boxenv/boxenv_test.go b/internal/adapter/boxenv/boxenv_test.go new file mode 100644 index 0000000000..84258170b3 --- /dev/null +++ b/internal/adapter/boxenv/boxenv_test.go @@ -0,0 +1,300 @@ +package boxenv + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "io/fs" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/stacklok/mecatl/engine/session" + "github.com/stacklok/mecatl/engine/tool" + "github.com/stacklok/mecatl/internal/adapter/server" +) + +type fakeBoxAPI struct { + t *testing.T + mu sync.Mutex + files map[string][]byte + state string + createCalls int + resumeCalls int + commandCalls int + lastCreate createBoxRequest + lastCommand commandRequest +} + +func newFakeBoxAPI(t *testing.T) (*fakeBoxAPI, *httptest.Server) { + t.Helper() + fake := &fakeBoxAPI{t: t, files: make(map[string][]byte), state: "ready"} + server := httptest.NewServer(http.HandlerFunc(fake.serveHTTP)) + t.Cleanup(server.Close) + return fake, server +} + +func (f *fakeBoxAPI) serveHTTP(w http.ResponseWriter, r *http.Request) { + f.t.Helper() + if got := r.Header.Get("Authorization"); got != "Bearer test-key" { + f.t.Errorf("Authorization = %q, want bearer test key", got) + writeJSON(w, http.StatusUnauthorized, map[string]any{"code": "unauthorized", "message": "bad auth"}) + return + } + + switch { + case r.Method == http.MethodPost && r.URL.Path == "/api/box/v1/boxes": + var in createBoxRequest + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + f.t.Errorf("decode create: %v", err) + writeJSON(w, http.StatusBadRequest, map[string]any{"code": "bad_request"}) + return + } + f.mu.Lock() + f.createCalls++ + f.lastCreate = in + state := f.state + f.mu.Unlock() + writeJSON(w, http.StatusOK, map[string]any{"box": map[string]any{"id": "box-1", "state": state}}) + + case r.Method == http.MethodGet && r.URL.Path == "/api/box/v1/boxes/box-1": + f.mu.Lock() + state := f.state + f.mu.Unlock() + writeJSON(w, http.StatusOK, map[string]any{"box": map[string]any{"id": "box-1", "state": state}}) + + case r.Method == http.MethodPost && r.URL.Path == "/api/box/v1/boxes/box-1/resume": + f.mu.Lock() + f.resumeCalls++ + f.state = "ready" + f.mu.Unlock() + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + + case r.Method == http.MethodPost && r.URL.Path == "/api/box/v1/boxes/box-1/stop": + f.mu.Lock() + f.state = "stopped" + f.mu.Unlock() + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + + case r.Method == http.MethodPost && r.URL.Path == "/api/box/v1/boxes/box-1/commands": + var in commandRequest + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + f.t.Errorf("decode command: %v", err) + writeJSON(w, http.StatusBadRequest, map[string]any{"code": "bad_request"}) + return + } + f.mu.Lock() + f.commandCalls++ + f.lastCommand = in + f.mu.Unlock() + + result := commandResult{} + switch { + case in.Command == "pwd": + result.Stdout = "/home/oai/share/workspace\n" + case in.Command == "find . -type f -print0": + f.mu.Lock() + for name := range f.files { + if strings.HasPrefix(name, boxWorkspace+"/") { + result.Stdout += "./" + strings.TrimPrefix(name, boxWorkspace+"/") + "\x00" + } + } + f.mu.Unlock() + case strings.Contains(in.Command, "stat -c"): + result.Stdout = "f\n644\n5\n123\n" + } + writeJSON(w, http.StatusOK, map[string]any{"result": result}) + + case r.Method == http.MethodGet && r.URL.Path == "/api/box/v1/boxes/box-1/files": + name := r.URL.Query().Get("path") + f.mu.Lock() + data, ok := f.files[name] + f.mu.Unlock() + if !ok { + writeJSON(w, http.StatusNotFound, map[string]any{"code": "file_not_found", "message": "missing"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"content": base64.StdEncoding.EncodeToString(data)}) + + case r.Method == http.MethodPut && r.URL.Path == "/api/box/v1/boxes/box-1/files": + var in struct { + Path string `json:"path"` + Content string `json:"content"` + Encoding string `json:"encoding"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + f.t.Errorf("decode write: %v", err) + writeJSON(w, http.StatusBadRequest, map[string]any{"code": "bad_request"}) + return + } + data, err := base64.StdEncoding.DecodeString(in.Content) + if err != nil || in.Encoding != "base64" { + f.t.Errorf("invalid file write encoding=%q err=%v", in.Encoding, err) + writeJSON(w, http.StatusBadRequest, map[string]any{"code": "bad_encoding"}) + return + } + f.mu.Lock() + f.files[in.Path] = data + f.mu.Unlock() + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + + default: + f.t.Errorf("unexpected request: %s %s", r.Method, r.URL.String()) + writeJSON(w, http.StatusNotFound, map[string]any{"code": "not_found", "message": "unexpected"}) + } +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +func newTestProvider(t *testing.T, endpoint string) *Provider { + t.Helper() + provider, err := NewProvider(Config{ + APIKey: "test-key", + BaseURL: endpoint + "/api/box/v1", + Scope: "test", + TTLSeconds: 123, + ReadyTimeout: time.Second, + PollInterval: time.Millisecond, + }) + if err != nil { + t.Fatalf("NewProvider: %v", err) + } + return provider +} + +func TestProviderBindDefaultCreatesIsolatedBox(t *testing.T) { + fake, endpoint := newFakeBoxAPI(t) + provider := newTestProvider(t, endpoint.URL) + binding, err := provider.Bind(t.Context(), server.PlacementBindRequest{ + Selector: server.DefaultPlacement(), Scope: "test", Operation: server.PlacementOperationCreate, + }) + if err != nil { + t.Fatalf("Bind: %v", err) + } + if binding.Ref.Kind != Kind || binding.Ref.ID != "box-1" || binding.Ref.Revision != providerVersion { + t.Fatalf("Ref = %#v", binding.Ref) + } + if binding.Environment.Ref() != binding.Ref || binding.Environment.Workspace().Root() != workspaceRoot { + t.Fatalf("binding/env affinity mismatch: ref=%#v root=%q", binding.Environment.Ref(), binding.Environment.Workspace().Root()) + } + runner := binding.Environment.CommandRunner() + bound, ok := runner.(interface{ BoundWorkspaceRoot() string }) + if !ok || bound.BoundWorkspaceRoot() != workspaceRoot { + t.Fatalf("runner does not report bound root %q", workspaceRoot) + } + result, err := runner.Run(t.Context(), "pwd") + if err != nil || result.ExitCode != 0 { + t.Fatalf("Run = %#v, %v", result, err) + } + + fake.mu.Lock() + defer fake.mu.Unlock() + if fake.createCalls != 1 { + t.Fatalf("create calls = %d, want 1", fake.createCalls) + } + if !fake.lastCreate.NoEnv || fake.lastCreate.TTLSeconds != 123 { + t.Fatalf("create request = %#v, want noEnv=true ttl=123", fake.lastCreate) + } + if fake.lastCommand.Cwd != boxWorkspace { + t.Fatalf("Shell cwd = %q, want %q", fake.lastCommand.Cwd, boxWorkspace) + } +} + +func TestProviderReattachResumesExactBoxWithoutCreating(t *testing.T) { + fake, endpoint := newFakeBoxAPI(t) + fake.state = "stopped" + provider := newTestProvider(t, endpoint.URL) + ref := sessionRef("box-1") + binding, err := provider.Reattach(t.Context(), server.PlacementReattachRequest{Ref: ref, Scope: "test"}) + if err != nil { + t.Fatalf("Reattach: %v", err) + } + if binding.Ref != ref { + t.Fatalf("Ref = %#v, want %#v", binding.Ref, ref) + } + fake.mu.Lock() + defer fake.mu.Unlock() + if fake.createCalls != 0 || fake.resumeCalls != 1 { + t.Fatalf("create=%d resume=%d, want 0/1", fake.createCalls, fake.resumeCalls) + } +} + +func TestWorkspaceVersionedMutationAndGrep(t *testing.T) { + fake, endpoint := newFakeBoxAPI(t) + provider := newTestProvider(t, endpoint.URL) + binding, err := provider.Bind(t.Context(), server.PlacementBindRequest{ + Selector: server.DefaultPlacement(), Scope: "test", Operation: server.PlacementOperationCreate, + }) + if err != nil { + t.Fatalf("Bind: %v", err) + } + ws := binding.Environment.Workspace() + + created, err := ws.CreateFile(t.Context(), "dir/a.txt", []byte("hello\nworld\n")) + if err != nil { + t.Fatalf("CreateFile: %v", err) + } + data, readVersion, err := ws.ReadVersion(t.Context(), "dir/a.txt") + if err != nil || string(data) != "hello\nworld\n" || !created.Equal(readVersion) { + t.Fatalf("ReadVersion data=%q created/read equal=%v err=%v", data, created.Equal(readVersion), err) + } + + matches, err := ws.Grep(t.Context(), "hello", "**/*.txt") + if err != nil || len(matches) != 1 || matches[0].Path != "dir/a.txt" || matches[0].Line != 1 { + t.Fatalf("Grep = %#v, %v", matches, err) + } + + fake.mu.Lock() + fake.files["workspace/dir/a.txt"] = []byte("changed elsewhere") + fake.mu.Unlock() + _, err = ws.ReplaceFile(t.Context(), "dir/a.txt", readVersion, []byte("new")) + var mismatch *tool.VersionMismatchError + if !errors.As(err, &mismatch) { + t.Fatalf("ReplaceFile error = %v, want VersionMismatchError", err) + } + + if _, err := ws.CreateFile(t.Context(), "../escape", []byte("x")); !errors.Is(err, ErrPathEscape) { + t.Fatalf("CreateFile escape = %v, want ErrPathEscape", err) + } + if _, err := ws.Read(t.Context(), "/etc/passwd"); !errors.Is(err, ErrPathEscape) { + t.Fatalf("Read escape = %v, want ErrPathEscape", err) + } +} + +func TestAPIOkFalseIsFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{"ok": false, "code": "denied", "message": "no"}) + })) + defer server.Close() + base, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + client := &apiClient{base: base, apiKey: "test-key", http: server.Client()} + if err := client.request(context.Background(), http.MethodGet, "/x", nil, nil, nil); err == nil { + t.Fatal("request returned nil error for ok=false") + } +} + +func TestReadMissingWrapsFSNotExist(t *testing.T) { + _, endpoint := newFakeBoxAPI(t) + provider := newTestProvider(t, endpoint.URL) + ws := &Workspace{client: provider.client, boxID: "box-1"} + _, err := ws.Read(t.Context(), "missing.txt") + if !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("Read missing = %v, want fs.ErrNotExist", err) + } +} + +func sessionRef(id string) session.EnvironmentRef { + return session.EnvironmentRef{Kind: Kind, ID: id, Revision: providerVersion} +} From ca51ea8c32dee4542d08839c1857ec24884d3b8b Mon Sep 17 00:00:00 2001 From: Yossi Eliaz Date: Tue, 15 Sep 2026 21:27:02 +0300 Subject: [PATCH 8/8] feat(boxenv): support workspace namespace operations --- internal/adapter/boxenv/namespace.go | 172 +++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 internal/adapter/boxenv/namespace.go diff --git a/internal/adapter/boxenv/namespace.go b/internal/adapter/boxenv/namespace.go new file mode 100644 index 0000000000..781621122f --- /dev/null +++ b/internal/adapter/boxenv/namespace.go @@ -0,0 +1,172 @@ +package boxenv + +import ( + "context" + "errors" + "fmt" + "io/fs" + "path" + "sort" + "strconv" + "strings" + "time" + + "github.com/stacklok/mecatl/engine/tool" +) + +var _ tool.WorkspaceNamespace = (*Workspace)(nil) + +func (w *Workspace) ReadDir(ctx context.Context, p string) ([]tool.FileInfo, error) { + dir, err := cleanDirPath(p) + if err != nil { + return nil, err + } + q := shellQuote(dir) + cmd := "if [ " + q + " != '.' ] && [ ! -e " + q + " ] && [ ! -L " + q + " ]; then exit 44; fi; " + + "if [ ! -d " + q + " ]; then exit 46; fi; " + + "find " + q + " -mindepth 1 -maxdepth 1 -printf '%f\\0%y\\0%s\\0%T@\\0'" + result, err := w.client.command(ctx, w.boxID, commandRequest{Command: cmd, Cwd: boxWorkspace, TimeoutSeconds: 20}) + if err != nil { + return nil, err + } + switch result.ExitCode { + case 44: + return nil, &fs.PathError{Op: "readdir", Path: p, Err: fs.ErrNotExist} + case 46: + return nil, &fs.PathError{Op: "readdir", Path: p, Err: fs.ErrInvalid} + case 0: + default: + return nil, fmt.Errorf("boxenv: readdir %q exited %d", p, result.ExitCode) + } + if result.Stdout == "" { + return []tool.FileInfo{}, nil + } + fields := strings.Split(result.Stdout, "\x00") + if len(fields) > 0 && fields[len(fields)-1] == "" { + fields = fields[:len(fields)-1] + } + if len(fields)%4 != 0 { + return nil, fmt.Errorf("boxenv: malformed readdir response") + } + entries := make([]tool.FileInfo, 0, len(fields)/4) + for i := 0; i < len(fields); i += 4 { + size, err := strconv.ParseInt(fields[i+2], 10, 64) + if err != nil { + return nil, fmt.Errorf("boxenv: parse readdir size: %w", err) + } + seconds, err := strconv.ParseFloat(fields[i+3], 64) + if err != nil { + return nil, fmt.Errorf("boxenv: parse readdir mtime: %w", err) + } + isDir := fields[i+1] == "d" + mode := fs.FileMode(0o644) + if isDir { + mode = fs.ModeDir | 0o755 + } + entries = append(entries, tool.FileInfo{ + Name: fields[i], Size: size, Mode: mode, + ModTime: time.Unix(0, int64(seconds*float64(time.Second))), IsDir: isDir, + }) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name }) + return entries, nil +} + +func (w *Workspace) Remove(ctx context.Context, p string) error { + key, err := cleanPath(p) + if err != nil { + return err + } + q := shellQuote(key) + cmd := "if [ ! -e " + q + " ] && [ ! -L " + q + " ]; then exit 44; fi; " + + "if [ -d " + q + " ] && [ ! -L " + q + " ]; then rmdir -- " + q + " 2>/dev/null || exit 45; else rm -f -- " + q + " || exit 46; fi" + w.mu.Lock() + defer w.mu.Unlock() + result, err := w.client.command(ctx, w.boxID, commandRequest{Command: cmd, Cwd: boxWorkspace, TimeoutSeconds: 10}) + if err != nil { + return err + } + switch result.ExitCode { + case 0: + return nil + case 44: + return &fs.PathError{Op: "remove", Path: p, Err: fs.ErrNotExist} + case 45: + return &fs.PathError{Op: "remove", Path: p, Err: tool.ErrDirectoryNotEmpty} + default: + return fmt.Errorf("boxenv: remove %q exited %d", p, result.ExitCode) + } +} + +func (w *Workspace) Rename(ctx context.Context, oldPath, newPath string) error { + oldKey, err := cleanPath(oldPath) + if err != nil { + return err + } + newKey, err := cleanPath(newPath) + if err != nil { + return err + } + oldQ, newQ := shellQuote(oldKey), shellQuote(newKey) + parentQ := shellQuote(path.Dir(newKey)) + cmd := "if [ ! -e " + oldQ + " ] && [ ! -L " + oldQ + " ]; then exit 44; fi; " + + "if [ -e " + newQ + " ] || [ -L " + newQ + " ]; then exit 45; fi; " + + "mkdir -p -- " + parentQ + " || exit 46; mv -- " + oldQ + " " + newQ + " || exit 46" + w.mu.Lock() + defer w.mu.Unlock() + result, err := w.client.command(ctx, w.boxID, commandRequest{Command: cmd, Cwd: boxWorkspace, TimeoutSeconds: 20}) + if err != nil { + return err + } + switch result.ExitCode { + case 0: + return nil + case 44: + return &fs.PathError{Op: "rename", Path: oldPath, Err: fs.ErrNotExist} + case 45: + return &fs.PathError{Op: "rename", Path: newPath, Err: fs.ErrExist} + default: + return fmt.Errorf("boxenv: rename %q to %q exited %d", oldPath, newPath, result.ExitCode) + } +} + +func (w *Workspace) CopyFile(ctx context.Context, source, destination string) (tool.FileVersion, error) { + srcKey, err := cleanPath(source) + if err != nil { + return tool.FileVersion{}, err + } + dstKey, err := cleanPath(destination) + if err != nil { + return tool.FileVersion{}, err + } + w.mu.Lock() + defer w.mu.Unlock() + + data, err := w.client.readFile(ctx, w.boxID, boxPath(srcKey)) + if err != nil { + return tool.FileVersion{}, err + } + if _, err := w.client.readFile(ctx, w.boxID, boxPath(dstKey)); err == nil { + return tool.FileVersion{}, &fs.PathError{Op: "copy", Path: destination, Err: fs.ErrExist} + } else if !isFileMissing(err) { + return tool.FileVersion{}, err + } + if err := w.ensureParent(ctx, dstKey); err != nil { + return tool.FileVersion{}, err + } + if err := w.client.writeFile(ctx, w.boxID, boxPath(dstKey), data); err != nil { + return tool.FileVersion{}, err + } + return versionOf(data), nil +} + +func cleanDirPath(p string) (string, error) { + if p == "" || p == "." { + return ".", nil + } + return cleanPath(p) +} + +func isFileMissing(err error) bool { + return errors.Is(err, fs.ErrNotExist) +}