Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 41 additions & 14 deletions agent-governance-golang/packages/agentmesh/sandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"crypto/rand"
"encoding/hex"
"fmt"
"math"
"os/exec"
"regexp"
"strings"
Expand All @@ -31,9 +32,10 @@ func randomHex(n int) string {
return hex.EncodeToString(b)
}

// Docker subprocess deadlines. ``docker exec`` runs user-supplied code so
// it falls back to SandboxConfig.TimeoutSeconds when set; the others are
// short-lived control-plane operations against the local Docker daemon.
// Docker subprocess deadlines. “docker exec“ runs user-supplied code so it
// uses SandboxConfig.TimeoutSeconds when set (falling back to dockerExecTimeout
// otherwise); the others are short-lived control-plane operations against the
// local Docker daemon.
const (
dockerInfoTimeout = 5 * time.Second
dockerStartTimeout = 30 * time.Second
Expand Down Expand Up @@ -124,11 +126,22 @@ type SandboxProvider interface {

// DockerSandboxProvider implements SandboxProvider using the Docker CLI.
type DockerSandboxProvider struct {
image string
containers map[string]string // key: "agentID:sessionID", value: container name
mu sync.Mutex
availableOnce sync.Once
available bool
image string
containers map[string]string // key: "agentID:sessionID", value: container name
execTimeouts map[string]time.Duration // key: "agentID:sessionID", value: docker exec deadline
mu sync.Mutex
availableOnce sync.Once
available bool
}

// resolveExecTimeout converts a SandboxConfig.TimeoutSeconds value into a
// docker-exec deadline. Non-positive or non-finite values fall back to
// dockerExecTimeout so a misconfigured session cannot disable the deadline.
func resolveExecTimeout(seconds float64) time.Duration {
if seconds > 0 && !math.IsInf(seconds, 0) && !math.IsNaN(seconds) {
return time.Duration(seconds * float64(time.Second))
}
return dockerExecTimeout
}

// NewDockerSandboxProvider creates a DockerSandboxProvider with the given base image.
Expand All @@ -140,8 +153,9 @@ type DockerSandboxProvider struct {
// that exercise other code paths — never pay the `docker info` cost.
func NewDockerSandboxProvider(image string) *DockerSandboxProvider {
return &DockerSandboxProvider{
image: image,
containers: make(map[string]string),
image: image,
containers: make(map[string]string),
execTimeouts: make(map[string]time.Duration),
}
}

Expand Down Expand Up @@ -232,14 +246,20 @@ func (p *DockerSandboxProvider) CreateSession(agentID string, config *SandboxCon
if p.containers == nil {
p.containers = make(map[string]string)
}
if p.execTimeouts == nil {
p.execTimeouts = make(map[string]time.Duration)
}
// Evict oldest tracked container if at capacity
if len(p.containers) >= maxTrackedContainers {
for k := range p.containers {
delete(p.containers, k)
delete(p.execTimeouts, k)
break
}
}
p.containers[containerKey(agentID, sessionID)] = name
key := containerKey(agentID, sessionID)
p.containers[key] = name
p.execTimeouts[key] = resolveExecTimeout(config.TimeoutSeconds)
p.mu.Unlock()

return &SessionHandle{
Expand All @@ -252,18 +272,24 @@ func (p *DockerSandboxProvider) CreateSession(agentID string, config *SandboxCon
// ExecuteCode runs a command inside an existing sandbox session container.
func (p *DockerSandboxProvider) ExecuteCode(agentID, sessionID, code string) (*ExecutionHandle, error) {
p.mu.Lock()
name, ok := p.containers[containerKey(agentID, sessionID)]
key := containerKey(agentID, sessionID)
name, ok := p.containers[key]
execTimeout := p.execTimeouts[key]
p.mu.Unlock()
if !ok {
return nil, fmt.Errorf("session %s:%s not found", agentID, sessionID)
}
// Sessions created before a timeout was tracked fall back to the default.
if execTimeout <= 0 {
execTimeout = dockerExecTimeout
}

// Same collision concern as sessionID — keep the nanosecond prefix
// for human-readable ordering and append a random suffix.
execID := fmt.Sprintf("exec-%d-%s", time.Now().UnixNano(), randomHex(4))
start := time.Now()

ctx, cancel := context.WithTimeout(context.Background(), dockerExecTimeout)
ctx, cancel := context.WithTimeout(context.Background(), execTimeout)
defer cancel()
// Avoid shell interpolation: pipe code via stdin instead of sh -c.
cmd := exec.CommandContext(ctx, "docker", "exec", "-i", name, "sh")
Expand All @@ -278,7 +304,7 @@ func (p *DockerSandboxProvider) ExecuteCode(agentID, sessionID, code string) (*E
exitCode := 0
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("docker exec timed out after %s", dockerExecTimeout)
return nil, fmt.Errorf("docker exec timed out after %s", execTimeout)
}
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
Expand Down Expand Up @@ -316,6 +342,7 @@ func (p *DockerSandboxProvider) DestroySession(agentID, sessionID string) error
name, ok := p.containers[key]
if ok {
delete(p.containers, key)
delete(p.execTimeouts, key)
}
p.mu.Unlock()

Expand Down
23 changes: 23 additions & 0 deletions agent-governance-golang/packages/agentmesh/sandbox_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package agentmesh

import (
"math"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -166,3 +167,25 @@ func TestRandomHexProducesUniqueValues(t *testing.T) {
seen[v] = struct{}{}
}
}

func TestResolveExecTimeout(t *testing.T) {
tests := []struct {
name string
seconds float64
want time.Duration
}{
{"custom positive honored", 2, 2 * time.Second},
{"fractional honored", 1.5, 1500 * time.Millisecond},
{"zero falls back to default", 0, dockerExecTimeout},
{"negative falls back to default", -5, dockerExecTimeout},
{"NaN falls back to default", math.NaN(), dockerExecTimeout},
{"Inf falls back to default", math.Inf(1), dockerExecTimeout},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := resolveExecTimeout(tc.seconds); got != tc.want {
t.Errorf("resolveExecTimeout(%v) = %s, want %s", tc.seconds, got, tc.want)
}
})
}
}
Loading