let workspace to support multiple paths, "workspace": "~/.picoclaw/workspace|path2....|path3.......",

This commit is contained in:
lyqu 2026-02-22 17:42:19 -05:00
parent 6b429de927
commit 3dfbc00559
6 changed files with 112 additions and 37 deletions

View file

@ -78,10 +78,11 @@ func gatewayCmd() {
// Setup cron tool and service
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
combinedWorkspace := cfg.WorkspacePath()
cronService := setupCronTool(
agentLoop,
msgBus,
cfg.WorkspacePath(),
combinedWorkspace,
cfg.Agents.Defaults.RestrictToWorkspace,
execTimeout,
cfg,
@ -224,12 +225,13 @@ func gatewayCmd() {
func setupCronTool(
agentLoop *agent.AgentLoop,
msgBus *bus.MessageBus,
workspace string,
workspace string, // workspace can contain '|' for allowed paths
restrict bool,
execTimeout time.Duration,
cfg *config.Config,
) *cron.CronService {
cronStorePath := filepath.Join(workspace, "cron", "jobs.json")
primaryWorkspace := strings.Split(workspace, "|")[0]
cronStorePath := filepath.Join(primaryWorkspace, "cron", "jobs.json")
// Create cron service
cronService := cron.NewCronService(cronStorePath, nil)

View file

@ -30,6 +30,9 @@ func getGlobalConfigDir() string {
}
func NewContextBuilder(workspace string) *ContextBuilder {
// workspace can be a multiplexed string separated by '|'
primaryWorkspace := strings.Split(workspace, "|")[0]
// builtin skills: skills directory in current project
// Use the skills/ directory under the current working directory
wd, _ := os.Getwd()
@ -38,8 +41,8 @@ func NewContextBuilder(workspace string) *ContextBuilder {
return &ContextBuilder{
workspace: workspace,
skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir),
memory: NewMemoryStore(workspace),
skillsLoader: skills.NewSkillsLoader(primaryWorkspace, globalSkillsDir, builtinSkillsDir),
memory: NewMemoryStore(primaryWorkspace),
}
}
@ -50,12 +53,25 @@ func (cb *ContextBuilder) SetToolsRegistry(registry *tools.ToolRegistry) {
func (cb *ContextBuilder) getIdentity() string {
now := time.Now().Format("2006-01-02 15:04 (Monday)")
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
allowedPaths := strings.Split(cb.workspace, "|")
primaryWorkspace := allowedPaths[0]
workspacePath, _ := filepath.Abs(primaryWorkspace)
runtime := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version())
// Build tools section dynamically
toolsSection := cb.buildToolsSection()
var authorizedDirs strings.Builder
for i, p := range allowedPaths {
absP, _ := filepath.Abs(p)
if i == 0 {
authorizedDirs.WriteString(fmt.Sprintf("- %s (Primary Workspace)\n", absP))
} else {
authorizedDirs.WriteString(fmt.Sprintf("- %s (Authorized Directory)\n", absP))
}
}
return fmt.Sprintf(`# picoclaw 🦞
You are picoclaw, a helpful AI assistant.
@ -67,7 +83,10 @@ You are picoclaw, a helpful AI assistant.
%s
## Workspace
Your workspace is at: %s
Your primary workspace is at: %s
You are authorized to access and operate in the following directories:
%s
- Memory: %s/memory/MEMORY.md
- Daily Notes: %s/memory/YYYYMM/YYYYMMDD.md
- Skills: %s/skills/{skill-name}/SKILL.md
@ -81,7 +100,7 @@ Your workspace is at: %s
2. **Be helpful and accurate** - When using tools, briefly explain what you're doing.
3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md`,
now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, workspacePath)
now, runtime, workspacePath, authorizedDirs.String(), workspacePath, workspacePath, workspacePath, toolsSection, workspacePath)
}
func (cb *ContextBuilder) buildToolsSection() string {
@ -148,9 +167,11 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string {
"IDENTITY.md",
}
primaryWorkspace := strings.Split(cb.workspace, "|")[0]
var sb strings.Builder
for _, filename := range bootstrapFiles {
filePath := filepath.Join(cb.workspace, filename)
filePath := filepath.Join(primaryWorkspace, filename)
if data, err := os.ReadFile(filePath); err == nil {
fmt.Fprintf(&sb, "## %s\n\n%s\n\n", filename, data)
}

View file

@ -41,7 +41,10 @@ func NewAgentInstance(
provider providers.LLMProvider,
) *AgentInstance {
workspace := resolveAgentWorkspace(agentCfg, defaults)
os.MkdirAll(workspace, 0o755)
// workspace can contain multiple paths separated by '|'
primaryWorkspace := strings.Split(workspace, "|")[0]
os.MkdirAll(primaryWorkspace, 0o755)
model := resolveAgentModel(agentCfg, defaults)
fallbacks := resolveAgentFallbacks(agentCfg, defaults)
@ -55,7 +58,7 @@ func NewAgentInstance(
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict))
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict))
sessionsDir := filepath.Join(workspace, "sessions")
sessionsDir := filepath.Join(primaryWorkspace, "sessions")
sessionsManager := session.NewSessionManager(sessionsDir)
contextBuilder := NewContextBuilder(workspace)
@ -148,6 +151,16 @@ func expandHome(path string) string {
if path == "" {
return path
}
// Handle multiple paths separated by |
if strings.Contains(path, "|") {
parts := strings.Split(path, "|")
for i, p := range parts {
parts[i] = expandHome(strings.TrimSpace(p))
}
return strings.Join(parts, "|")
}
if path[0] == '~' {
home, _ := os.UserHomeDir()
if len(path) > 1 && path[1] == '/' {

View file

@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"sync/atomic"
"github.com/caarlos0/env/v11"
@ -587,6 +588,16 @@ func expandHome(path string) string {
if path == "" {
return path
}
// Handle multiple paths separated by |
if strings.Contains(path, "|") {
parts := strings.Split(path, "|")
for i, p := range parts {
parts[i] = expandHome(strings.TrimSpace(p))
}
return strings.Join(parts, "|")
}
if path[0] == '~' {
home, _ := os.UserHomeDir()
if len(path) > 1 && path[1] == '/' {

View file

@ -8,53 +8,81 @@ import (
"strings"
)
// validatePath ensures the given path is within the workspace if restrict is true.
func validatePath(path, workspace string, restrict bool) (string, error) {
// validatePath ensures the given path is within one of the allowed paths if restrict is true.
// workspace can be a single path or multiple paths separated by '|'.
func validatePath(path string, workspace string, restrict bool) (string, error) {
if workspace == "" {
return path, nil
}
absWorkspace, err := filepath.Abs(workspace)
allowedPaths := strings.Split(workspace, "|")
primaryWorkspace := allowedPaths[0]
absPrimary, err := filepath.Abs(primaryWorkspace)
if err != nil {
return "", fmt.Errorf("failed to resolve workspace path: %w", err)
return "", fmt.Errorf("failed to resolve primary workspace path: %w", err)
}
var absPath string
if filepath.IsAbs(path) {
absPath = filepath.Clean(path)
} else {
absPath, err = filepath.Abs(filepath.Join(absWorkspace, path))
absPath, err = filepath.Abs(filepath.Join(absPrimary, path))
if err != nil {
return "", fmt.Errorf("failed to resolve file path: %w", err)
}
}
if restrict {
if !isWithinWorkspace(absPath, absWorkspace) {
found := false
for _, wp := range allowedPaths {
absWP, _ := filepath.Abs(wp)
if isWithinWorkspace(absPath, absWP) {
found = true
break
}
}
if !found {
return "", fmt.Errorf("access denied: path is outside the workspace")
}
// Symlink identification and deeper validation
var resolved string
workspaceReal := absWorkspace
if resolved, err = filepath.EvalSymlinks(absWorkspace); err == nil {
workspaceReal = resolved
}
if resolved, err = filepath.EvalSymlinks(absPath); err == nil {
if !isWithinWorkspace(resolved, workspaceReal) {
resFound := false
for _, wp := range allowedPaths {
absWP, _ := filepath.Abs(wp)
resolvedWP := absWP
if r, err := filepath.EvalSymlinks(absWP); err == nil {
resolvedWP = r
}
if isWithinWorkspace(resolved, resolvedWP) {
resFound = true
break
}
}
if !resFound {
return "", fmt.Errorf("access denied: symlink resolves outside workspace")
}
} else if os.IsNotExist(err) {
var parentResolved string
if parentResolved, err = resolveExistingAncestor(filepath.Dir(absPath)); err == nil {
if !isWithinWorkspace(parentResolved, workspaceReal) {
resFound := false
for _, wp := range allowedPaths {
absWP, _ := filepath.Abs(wp)
resolvedWP := absWP
if r, err := filepath.EvalSymlinks(absWP); err == nil {
resolvedWP = r
}
if isWithinWorkspace(parentResolved, resolvedWP) {
resFound = true
break
}
}
if !resFound {
return "", fmt.Errorf("access denied: symlink resolves outside workspace")
}
} else if !os.IsNotExist(err) {
return "", fmt.Errorf("failed to resolve path: %w", err)
}
} else {
return "", fmt.Errorf("failed to resolve path: %w", err)
}
}

View file

@ -285,11 +285,6 @@ func (t *ExecTool) guardCommand(command, cwd string) string {
return "Command blocked by safety guard (path traversal detected)"
}
cwdPath, err := filepath.Abs(cwd)
if err != nil {
return ""
}
pathPattern := regexp.MustCompile(`[A-Za-z]:\\[^\\\"']+|/[^\s\"']+`)
matches := pathPattern.FindAllString(cmd, -1)
@ -299,12 +294,17 @@ func (t *ExecTool) guardCommand(command, cwd string) string {
continue
}
rel, err := filepath.Rel(cwdPath, p)
if err != nil {
continue
allowedPaths := strings.Split(t.workingDir, "|")
found := false
for _, wp := range allowedPaths {
absWP, _ := filepath.Abs(wp)
if isWithinWorkspace(p, absWP) {
found = true
break
}
}
if strings.HasPrefix(rel, "..") {
if !found {
return "Command blocked by safety guard (path outside working dir)"
}
}