feat: introduce agent workspace synchronization to copy agent files and skills into sandbox containers.

This commit is contained in:
0x5487 2026-03-01 14:29:08 +08:00
parent e9ddf8cc8e
commit 3c5149768c
3 changed files with 224 additions and 0 deletions

View file

@ -341,6 +341,13 @@ func (c *ContainerSandbox) ensureContainer(ctx context.Context) error {
} }
} }
if c.cfg.WorkspaceAccess == string(config.WorkspaceAccessNone) && strings.TrimSpace(c.cfg.Workspace) != "" &&
strings.TrimSpace(c.cfg.AgentWorkspace) != "" {
if err := syncAgentWorkspace(c.cfg.AgentWorkspace, c.cfg.Workspace); err != nil {
logger.WarnCF("sandbox", "failed to sync agent workspace", map[string]any{"error": err})
}
}
hashMismatch := existing != nil && existing.ConfigHash != "" && existing.ConfigHash != c.hash hashMismatch := existing != nil && existing.ConfigHash != "" && existing.ConfigHash != c.hash
if hashMismatch { if hashMismatch {
hot := inspect.State.Running && (now-existing.LastUsedAtMs) < int64((5*time.Minute)/time.Millisecond) hot := inspect.State.Running && (now-existing.LastUsedAtMs) < int64((5*time.Minute)/time.Millisecond)

View file

@ -558,3 +558,94 @@ func TestNewContainerSandbox_SanitizesEnv(t *testing.T) {
t.Fatalf("LANG should be preserved or defaulted, got: %v", got) t.Fatalf("LANG should be preserved or defaulted, got: %v", got)
} }
} }
func TestSyncAgentWorkspace_SeedsFilesAndPreservesExisting(t *testing.T) {
agentWs := t.TempDir()
containerWs := t.TempDir()
// Setup agent workspace with seed files
agentAgentsFile := filepath.Join(agentWs, "AGENTS.md")
if err := os.WriteFile(agentAgentsFile, []byte("agent content"), 0o644); err != nil {
t.Fatalf("failed to create agent AGENTS.md: %v", err)
}
agentUserFile := filepath.Join(agentWs, "USER.md")
if err := os.WriteFile(agentUserFile, []byte("user content"), 0o644); err != nil {
t.Fatalf("failed to create agent USER.md: %v", err)
}
// Setup container workspace with PRE-EXISTING AGENTS.md (should not be overwritten)
containerAgentsFile := filepath.Join(containerWs, "AGENTS.md")
if err := os.WriteFile(containerAgentsFile, []byte("PRESERVED CONTENT"), 0o644); err != nil {
t.Fatalf("failed to create container AGENTS.md: %v", err)
}
// Run Sync
if err := syncAgentWorkspace(agentWs, containerWs); err != nil {
t.Fatalf("syncAgentWorkspace failed: %v", err)
}
// Verify existing file was preserved
content, err := os.ReadFile(containerAgentsFile)
if err != nil {
t.Fatalf("failed to read container AGENTS.md: %v", err)
}
if string(content) != "PRESERVED CONTENT" {
t.Fatalf("existing file was overwritten. expected PRESERVED CONTENT, got: %s", string(content))
}
// Verify missing file was seeded
content, err = os.ReadFile(filepath.Join(containerWs, "USER.md"))
if err != nil {
t.Fatalf("failed to read container USER.md: %v", err)
}
if string(content) != "user content" {
t.Fatalf("missing file was not seeded correctly. got: %s", string(content))
}
// Verify non-existent seed files are handled cleanly (TOOLS.md, MEMORY.md)
if _, err := os.Stat(filepath.Join(containerWs, "MEMORY.md")); !os.IsNotExist(err) {
t.Fatalf("expected MEMORY.md to not exist, got: %v", err)
}
}
func TestSyncAgentWorkspace_SyncsSkillsDirectory(t *testing.T) {
agentWs := t.TempDir()
containerWs := t.TempDir()
// Setup agent workspace with skills
agentSkillsDir := filepath.Join(agentWs, "skills")
if err := os.MkdirAll(agentSkillsDir, 0o755); err != nil {
t.Fatalf("failed to create agent skills dir: %v", err)
}
if err := os.WriteFile(filepath.Join(agentSkillsDir, "skill1.txt"), []byte("new skill"), 0o644); err != nil {
t.Fatalf("failed to create skill1: %v", err)
}
// Setup container workspace with OLD skills directory that should be overwritten
containerSkillsDir := filepath.Join(containerWs, "skills")
if err := os.MkdirAll(containerSkillsDir, 0o755); err != nil {
t.Fatalf("failed to create container skills dir: %v", err)
}
if err := os.WriteFile(filepath.Join(containerSkillsDir, "old-skill.txt"), []byte("old skill"), 0o644); err != nil {
t.Fatalf("failed to create old skill: %v", err)
}
// Run Sync
if err := syncAgentWorkspace(agentWs, containerWs); err != nil {
t.Fatalf("syncAgentWorkspace failed: %v", err)
}
// Verify old skills are gone and new skills are present
if _, err := os.Stat(filepath.Join(containerSkillsDir, "old-skill.txt")); !os.IsNotExist(err) {
t.Fatalf("old skill file was not removed during sync")
}
content, err := os.ReadFile(filepath.Join(containerSkillsDir, "skill1.txt"))
if err != nil {
t.Fatalf("failed to read synced skill: %v", err)
}
if string(content) != "new skill" {
t.Fatalf("skill file content mismatch. got: %s", string(content))
}
}

126
pkg/agent/sandbox/sync.go Normal file
View file

@ -0,0 +1,126 @@
package sandbox
import (
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"github.com/sipeed/picoclaw/pkg/logger"
)
var defaultSeedFiles = []string{
"AGENTS.md",
"MEMORY.md",
"IDENTITY.md",
"TOOLS.md",
"SOUL.md",
"BOOTSTRAP.md",
"USER.md",
}
// syncAgentWorkspace copies base agent files and the skills directory
// from the agentWorkspace to the isolated container workspace.
func syncAgentWorkspace(agentWorkspace, containerWorkspace string) error {
if agentWorkspace == "" || containerWorkspace == "" {
return nil
}
// 1. Seed base agent files
for _, file := range defaultSeedFiles {
src := filepath.Join(agentWorkspace, file)
dst := filepath.Join(containerWorkspace, file)
// Check if source exists
if _, err := os.Stat(src); err != nil {
if os.IsNotExist(err) {
continue
}
logger.WarnCF("sandbox", "failed to stat seed source file", map[string]any{"file": src, "error": err})
continue
}
// Check if destination already exists. If yes, preserve it.
if _, err := os.Stat(dst); err == nil {
continue // preserved
} else if !os.IsNotExist(err) {
logger.WarnCF("sandbox", "failed to stat seed destination file", map[string]any{"file": dst, "error": err})
continue
}
if err := copyFile(src, dst); err != nil {
logger.WarnCF("sandbox", "failed to seed file", map[string]any{"file": file, "error": err})
}
}
// 2. Sync skills directory (complete overwrite)
skillsSrc := filepath.Join(agentWorkspace, "skills")
skillsDst := filepath.Join(containerWorkspace, "skills")
if _, err := os.Stat(skillsSrc); err == nil {
// Remove existing destination to ensure clean sync
_ = os.RemoveAll(skillsDst)
if errCopy := copyDir(skillsSrc, skillsDst); errCopy != nil {
return fmt.Errorf("failed to sync skills directory: %w", errCopy)
}
} else if !os.IsNotExist(err) {
logger.WarnCF(
"sandbox",
"failed to stat skills source directory",
map[string]any{"dir": skillsSrc, "error": err},
)
}
return nil
}
// copyFile copies a single file from src to dst.
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
info, err := in.Stat()
if err != nil {
return err
}
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode())
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return out.Sync()
}
// copyDir recursively copies a directory tree, creating directories and copying files.
func copyDir(src, dst string) error {
return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(src, path)
if err != nil {
return err
}
targetPath := filepath.Join(dst, relPath)
if d.IsDir() {
info, err := d.Info()
if err != nil {
return err
}
return os.MkdirAll(targetPath, info.Mode())
}
return copyFile(path, targetPath)
})
}