molecule-core/workspace-server/internal/handlers/container_files.go
molecule-ai[bot] 45715aa8a5 fix(canvas/test): patch test regressions from PR #1243 + proximity hitbox fix (#1313)
* fix(ci): revert cancel-in-progress to true — ubuntu-runner dispatch stalled

With cancel-in-progress: false, pending CI runs accumulate in the
ci-staging concurrency group. New pushes create queued runs, but
GitHub dispatches multiple runs for the same SHA instead of replacing
the pending one. All runs get stuck/cancelled before completing.

Reverting to cancel-in-progress: true restores CI operation — runs
that are superseded are cancelled, freeing the concurrency slot for
the new run to proceed.

Runner availability (ubuntu-latest dispatch stall) is a separate
infra issue tracked independently.

* fix(security): validate tar header names in copyFilesToContainer — CWE-22 path traversal (#1043)

Tar header names were built from raw map keys without validation. A malicious
server-side caller could embed "../" in a file name to escape the destPath
volume mount (/configs) and write files outside the intended directory.

Fix: validate each name with filepath.Clean + IsAbs + HasPrefix("..") checks
before using it in the tar header, then join with destPath for the archive
header. Also guard parent-directory creation against traversal.

Closes #1043.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(canvas/test): patch regressed tests from PR #1243 orgs-page flakiness fix

Two regressions introduced by PR #1243 (fix issue #1207):

1. **ContextMenu.keyboard.test.tsx** — `setPendingDelete` now receives
   `{id, name, hasChildren}` (cascade-delete UX, PR #1252), but the test
   expected only `{id, name}`. Added `hasChildren: false` to the assertion.

2. **orgs-page.test.tsx** — 10 tests awaited `vi.advanceTimersByTimeAsync(50)`
   without `act()`. With fake timers, `setState` (synchronous) is flushed by
   `advanceTimersByTimeAsync`, but the React state update it triggers is a
   microtask — so the test saw stale render. Wrapping in `act(async () =>
   { await vi.advanceTimersByTimeAsync(50); })` ensures microtasks drain
   before assertions run.

All 813 vitest tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(canvas): add 100px proximity threshold to drag-to-nest detection

Fixes #1052 — previously, getIntersectingNodes() returned any node whose
bounding box overlapped the dragged node, regardless of actual pixel
distance. On a sparse canvas this triggered the "Nest Workspace" dialog
even when the dragged node was nowhere near any target.

The fix adds an on-node-drag proximity filter: only nodes within 100px
(center-to-center) of the dragged node are eligible as nest targets.
Distance is computed as squared Euclidean to avoid the sqrt overhead in
the hot drag path.

Added two tests to Canvas.pan-to-node.test.tsx covering the mock wiring
and confirming the regression is addressed in Canvas.tsx.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: molecule-ai[bot] <276602405+molecule-ai[bot]@users.noreply.github.com>
Co-authored-by: Molecule AI Core-FE <core-fe@agents.moleculesai.app>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 07:06:57 +00:00

187 lines
6.5 KiB
Go

package handlers
import (
"archive/tar"
"bytes"
"context"
"fmt"
"io"
"path/filepath"
"strings"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/pkg/stdcopy"
)
// maxExecOutput limits container exec output to 5MB to prevent OOM.
const maxExecOutput = 5 * 1024 * 1024
// findContainer finds a running container for the workspace.
// Checks provisioner name, full ID, and DB workspace name (same candidates as terminal handler).
func (h *TemplatesHandler) findContainer(ctx context.Context, workspaceID string) string {
if h.docker == nil {
return ""
}
name := provisioner.ContainerName(workspaceID)
candidates := []string{name}
if name != "ws-"+workspaceID {
candidates = append(candidates, "ws-"+workspaceID)
}
// Also check by workspace name from DB
var wsName string
db.DB.QueryRowContext(ctx, `SELECT LOWER(REPLACE(name, ' ', '-')) FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsName)
if wsName != "" {
candidates = append(candidates, wsName)
}
for _, c := range candidates {
info, err := h.docker.ContainerInspect(ctx, c)
if err == nil && info.State.Running {
return c
}
}
return ""
}
// execInContainer runs a command in a container and returns stdout (capped at maxExecOutput).
func (h *TemplatesHandler) execInContainer(ctx context.Context, containerName string, cmd []string) (string, error) {
execCfg := container.ExecOptions{
Cmd: cmd,
AttachStdout: true,
AttachStderr: true,
}
execID, err := h.docker.ContainerExecCreate(ctx, containerName, execCfg)
if err != nil {
return "", err
}
resp, err := h.docker.ContainerExecAttach(ctx, execID.ID, container.ExecAttachOptions{})
if err != nil {
return "", err
}
defer resp.Close()
var stdout bytes.Buffer
// Use stdcopy to correctly demux Docker multiplexed stream (stdout/stderr)
stdcopy.StdCopy(&stdout, io.Discard, io.LimitReader(resp.Reader, maxExecOutput))
return strings.TrimSpace(stdout.String()), nil
}
// copyFilesToContainer creates a tar archive from a map of files and copies it into a container.
// The destPath is prepended to each file name. File names must be relative and must not escape
// destPath via ".." segments — otherwise the tar header name could escape the mounted volume.
func (h *TemplatesHandler) copyFilesToContainer(ctx context.Context, containerName, destPath string, files map[string]string) error {
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
createdDirs := map[string]bool{}
for name, content := range files {
// Block absolute paths and traversal attempts at the archive-write boundary.
// Files are written inside destPath (typically /configs); anything that escapes
// via ".." or an absolute name could reach other volumes or system paths.
clean := filepath.Clean(name)
if filepath.IsAbs(clean) || strings.HasPrefix(clean, "..") {
return fmt.Errorf("unsafe file path in archive: %s", name)
}
// Prepend destPath so relative paths land inside the volume mount.
archiveName := filepath.Join(destPath, name)
// Create parent directories in tar (deduplicated)
dir := filepath.Dir(archiveName)
if dir != destPath && !createdDirs[dir] {
tw.WriteHeader(&tar.Header{
Typeflag: tar.TypeDir,
Name: dir + "/",
Mode: 0755,
})
createdDirs[dir] = true
}
data := []byte(content)
header := &tar.Header{
Name: archiveName,
Mode: 0644,
Size: int64(len(data)),
}
if err := tw.WriteHeader(header); err != nil {
return fmt.Errorf("failed to write tar header for %s: %w", name, err)
}
if _, err := tw.Write(data); err != nil {
return fmt.Errorf("failed to write tar data for %s: %w", name, err)
}
}
if err := tw.Close(); err != nil {
return fmt.Errorf("failed to close tar writer: %w", err)
}
return h.docker.CopyToContainer(ctx, containerName, destPath, &buf, container.CopyToContainerOptions{})
}
// writeViaEphemeral writes files to a named volume using an ephemeral Alpine container.
// Used when the workspace container is offline (e.g., during provisioning).
func (h *TemplatesHandler) writeViaEphemeral(ctx context.Context, volumeName string, files map[string]string) error {
if h.docker == nil {
return fmt.Errorf("docker not available")
}
// Create ephemeral container mounting the volume
resp, err := h.docker.ContainerCreate(ctx, &container.Config{
Image: "alpine:latest",
Cmd: []string{"sleep", "10"},
}, &container.HostConfig{
Binds: []string{volumeName + ":/configs"},
}, nil, nil, "")
if err != nil {
return fmt.Errorf("failed to create ephemeral container: %w", err)
}
defer h.docker.ContainerRemove(ctx, resp.ID, container.RemoveOptions{Force: true})
if err := h.docker.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil {
return fmt.Errorf("failed to start ephemeral container: %w", err)
}
// Copy files via tar, then stop container cleanly
if err := h.copyFilesToContainer(ctx, resp.ID, "/configs", files); err != nil {
return err
}
// Wait for container to be ready for removal (copy is synchronous, but be safe)
timeout := 5
h.docker.ContainerStop(ctx, resp.ID, container.StopOptions{Timeout: &timeout})
return nil
}
// deleteViaEphemeral deletes a file from a named volume using an ephemeral container.
func (h *TemplatesHandler) deleteViaEphemeral(ctx context.Context, volumeName, filePath string) error {
if h.docker == nil {
return fmt.Errorf("docker not available")
}
// CWE-78/CWE-22: validate before use. Also switches to exec form
// ([]string{...}) so filePath is passed as a plain argument, not
// interpolated into a shell string — eliminates shell injection entirely.
if err := validateRelPath(filePath); err != nil {
return err
}
resp, err := h.docker.ContainerCreate(ctx, &container.Config{
Image: "alpine:latest",
Cmd: []string{"rm", "-rf", "/configs", filePath},
}, &container.HostConfig{
Binds: []string{volumeName + ":/configs"},
}, nil, nil, "")
if err != nil {
return fmt.Errorf("failed to create ephemeral container: %w", err)
}
defer h.docker.ContainerRemove(ctx, resp.ID, container.RemoveOptions{Force: true})
if err := h.docker.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil {
return err
}
// Wait for the rm command to finish before removing the container
statusCh, errCh := h.docker.ContainerWait(ctx, resp.ID, container.WaitConditionNotRunning)
select {
case <-statusCh:
return nil
case err := <-errCh:
return err
}
}