feat: introduce agent workspace synchronization to copy agent files and skills into sandbox containers.
This commit is contained in:
parent
e9ddf8cc8e
commit
3c5149768c
3 changed files with 224 additions and 0 deletions
|
|
@ -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
|
||||
if hashMismatch {
|
||||
hot := inspect.State.Running && (now-existing.LastUsedAtMs) < int64((5*time.Minute)/time.Millisecond)
|
||||
|
|
|
|||
|
|
@ -558,3 +558,94 @@ func TestNewContainerSandbox_SanitizesEnv(t *testing.T) {
|
|||
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
126
pkg/agent/sandbox/sync.go
Normal 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)
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue