From 3c5149768ce731839f68e294ef871f27b62f8860 Mon Sep 17 00:00:00 2001 From: 0x5487 Date: Sun, 1 Mar 2026 14:29:08 +0800 Subject: [PATCH] feat: introduce agent workspace synchronization to copy agent files and skills into sandbox containers. --- pkg/agent/sandbox/container.go | 7 ++ pkg/agent/sandbox/container_test.go | 91 ++++++++++++++++++++ pkg/agent/sandbox/sync.go | 126 ++++++++++++++++++++++++++++ 3 files changed, 224 insertions(+) create mode 100644 pkg/agent/sandbox/sync.go diff --git a/pkg/agent/sandbox/container.go b/pkg/agent/sandbox/container.go index 4a9bd1587..045d7fe6a 100644 --- a/pkg/agent/sandbox/container.go +++ b/pkg/agent/sandbox/container.go @@ -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) diff --git a/pkg/agent/sandbox/container_test.go b/pkg/agent/sandbox/container_test.go index 4e149e819..4cae7ed0c 100644 --- a/pkg/agent/sandbox/container_test.go +++ b/pkg/agent/sandbox/container_test.go @@ -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)) + } +} diff --git a/pkg/agent/sandbox/sync.go b/pkg/agent/sandbox/sync.go new file mode 100644 index 000000000..bbf21cab5 --- /dev/null +++ b/pkg/agent/sandbox/sync.go @@ -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) + }) +}