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
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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
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