From 1fc04f7c7782ae870d8e1805068705be3d2e9881 Mon Sep 17 00:00:00 2001 From: Dark aura Date: Wed, 6 May 2026 03:15:59 +0100 Subject: [PATCH] feat: implement permission tool usage from frontend to backend Complete end-to-end permission flow for tool execution: - Add POST /api/permission/grant endpoint with gateway proxy - Add permission checks for list_dir, edit_file, append_file tools - Fix frontend PermissionPrompt to call grant API instead of chat messages - Add grantPermission() to frontend API client - Expose PermissionCache via AgentInstance for API access - Wire up permission grant callback through gateway to agent loop Inspired by opencode's permission system architecture. --- pkg/agent/agent_inject.go | 18 +++ pkg/agent/instance.go | 16 +++ pkg/agent/instance_test.go | 4 + pkg/gateway/gateway.go | 5 + pkg/health/server.go | 90 ++++++++++++-- pkg/tools/fs/edit.go | 99 +++++++++++++--- pkg/tools/fs/filesystem.go | 71 ++++++++++- web/backend/api/tools.go | 84 +++++++++++++ web/frontend/.npmrc | 1 + web/frontend/src/api/tools.ts | 23 ++++ .../permission/PermissionPrompt.tsx | 111 ++++++++++++++++++ web/frontend/src/i18n/locales/en.json | 10 ++ web/frontend/src/i18n/locales/zh.json | 10 ++ 13 files changed, 516 insertions(+), 26 deletions(-) create mode 100644 web/frontend/.npmrc create mode 100644 web/frontend/src/components/permission/PermissionPrompt.tsx diff --git a/pkg/agent/agent_inject.go b/pkg/agent/agent_inject.go index 6c0ad10da..5609bb4c5 100644 --- a/pkg/agent/agent_inject.go +++ b/pkg/agent/agent_inject.go @@ -3,6 +3,8 @@ package agent import ( + "fmt" + "github.com/sipeed/picoclaw/pkg/audio/asr" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" @@ -29,6 +31,22 @@ func (al *AgentLoop) GetRegistry() *AgentRegistry { return al.registry } +// GrantPermission grants permission for a path to a specific agent. +// agentID is the normalized agent ID (e.g. "main"), path is the file path, +// duration is "once" or "session". +// Returns an error if the agent is not found or the permission cache is not initialized. +func (al *AgentLoop) GrantPermission(agentID, path, duration string) error { + registry := al.GetRegistry() + if registry == nil { + return fmt.Errorf("agent registry not initialized") + } + agent, ok := registry.GetAgent(agentID) + if !ok { + return fmt.Errorf("agent %q not found", agentID) + } + return agent.GrantPermission(path, duration) +} + func (al *AgentLoop) GetConfig() *config.Config { al.mu.RLock() defer al.mu.RUnlock() diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 5bf1247d0..476ea8412 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -38,6 +38,7 @@ type AgentInstance struct { Sessions session.SessionStore ContextBuilder *ContextBuilder Tools *tools.ToolRegistry + PermissionCache *tools.PermissionCache // Exported for permission grant API Subagents *config.SubagentsConfig SkillsFilter []string Candidates []providers.FallbackCandidate @@ -255,6 +256,7 @@ func NewAgentInstance( Sessions: sessions, ContextBuilder: contextBuilder, Tools: toolsRegistry, + PermissionCache: permissionCache, // Store reference for API access Subagents: subagents, SkillsFilter: skillsFilter, Candidates: candidates, @@ -374,6 +376,20 @@ func (a *AgentInstance) Close() error { return nil } +// GrantPermission grants permission for a path with the specified duration. +// Duration can be "once" or "session". +// Returns an error if the PermissionCache is not initialized. +func (a *AgentInstance) GrantPermission(path, duration string) error { + if a.PermissionCache == nil { + return fmt.Errorf("permission cache not initialized for agent %s", a.ID) + } + if duration != "once" && duration != "session" { + return fmt.Errorf("invalid duration %q: must be 'once' or 'session'", duration) + } + a.PermissionCache.Grant(path, duration) + return nil +} + // initSessionStore creates the session persistence backend. // It uses the JSONL store by default and auto-migrates legacy JSON sessions. // Falls back to SessionManager if the JSONL store cannot be initialized or diff --git a/pkg/agent/instance_test.go b/pkg/agent/instance_test.go index 42bb53d86..96cd6eecf 100644 --- a/pkg/agent/instance_test.go +++ b/pkg/agent/instance_test.go @@ -324,6 +324,10 @@ func TestNewAgentInstance_AllowsMediaTempDirForReadListAndExec(t *testing.T) { if !ok { t.Fatal("list_dir tool not registered") } + // Grant permission for media temp dir if permission cache exists + if pc, ok := agent.PermissionCache.Check(mediaDir); ok && pc == "" { + agent.PermissionCache.Grant(mediaDir, "session") + } listResult := listTool.Execute(context.Background(), map[string]any{"path": mediaDir}) if listResult.IsError { t.Fatalf("list_dir should allow media temp dir, got: %s", listResult.ForLLM) diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index 4fd06d836..c9fac5615 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -242,6 +242,11 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) (runEr runningServices.HealthServer.SetReloadFunc(reloadTrigger) agentLoop.SetReloadFunc(reloadTrigger) + // Wire permission grant function to allow runtime permission grants via health endpoint + runningServices.HealthServer.SetPermissionGrantFunc(func(agentID, path, duration string) error { + return agentLoop.GrantPermission(agentID, path, duration) + }) + for _, bindHost := range listenResult.BindHosts { fmt.Printf("✓ Gateway started on %s\n", net.JoinHostPort(bindHost, strconv.Itoa(cfg.Gateway.Port))) } diff --git a/pkg/health/server.go b/pkg/health/server.go index 22346490c..540b35466 100644 --- a/pkg/health/server.go +++ b/pkg/health/server.go @@ -14,13 +14,14 @@ import ( ) type Server struct { - server *http.Server - mu sync.RWMutex - ready bool - checks map[string]Check - startTime time.Time - reloadFunc func() error - authToken string // optional bearer token for protected endpoints + server *http.Server + mu sync.RWMutex + ready bool + checks map[string]Check + startTime time.Time + reloadFunc func() error + permissionGrantFunc func(agentID, path, duration string) error + authToken string // optional bearer token for protected endpoints } type Check struct { @@ -49,6 +50,7 @@ func NewServer(host string, port int, token string) *Server { mux.HandleFunc("/health", s.healthHandler) mux.HandleFunc("/ready", s.readyHandler) mux.HandleFunc("/reload", s.reloadHandler) + mux.HandleFunc("/internal/permission/grant", s.permissionGrantHandler) addr := net.JoinHostPort(host, strconv.Itoa(port)) s.server = &http.Server{ @@ -119,6 +121,80 @@ func (s *Server) SetReloadFunc(fn func() error) { s.reloadFunc = fn } +// SetPermissionGrantFunc sets the callback function for granting permissions. +func (s *Server) SetPermissionGrantFunc(fn func(agentID, path, duration string) error) { + s.mu.Lock() + defer s.mu.Unlock() + s.permissionGrantFunc = fn +} + +// permissionGrantHandler handles POST /internal/permission/grant requests. +func (s *Server) permissionGrantHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusMethodNotAllowed) + json.NewEncoder(w).Encode(map[string]string{"error": "method not allowed, use POST"}) + return + } + + // Token check + s.mu.RLock() + requiredToken := s.authToken + s.mu.RUnlock() + + if requiredToken != "" { + given := extractBearerToken(r.Header.Get("Authorization")) + if given == "" || subtle.ConstantTimeCompare([]byte(given), []byte(requiredToken)) != 1 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"}) + return + } + } + + // Decode request body + var req struct { + AgentID string `json:"agent_id"` + Path string `json:"path"` + Duration string `json:"duration"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "invalid JSON: " + err.Error()}) + return + } + + if req.AgentID == "" || req.Path == "" || req.Duration == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "agent_id, path, and duration are required"}) + return + } + + s.mu.RLock() + grantFunc := s.permissionGrantFunc + s.mu.RUnlock() + + if grantFunc == nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusServiceUnavailable) + json.NewEncoder(w).Encode(map[string]string{"error": "permission grant not configured"}) + return + } + + if err := grantFunc(req.AgentID, req.Path, req.Duration); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + func (s *Server) reloadHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Content-Type", "application/json") diff --git a/pkg/tools/fs/edit.go b/pkg/tools/fs/edit.go index 4ba09ce93..b7ede89a8 100644 --- a/pkg/tools/fs/edit.go +++ b/pkg/tools/fs/edit.go @@ -8,6 +8,8 @@ import ( "path/filepath" "regexp" "strings" + + "github.com/sipeed/picoclaw/pkg/logger" ) // EditFileTool edits a file by replacing old_text with new_text. @@ -89,6 +91,25 @@ func (t *EditFileTool) Parameters() map[string]any { } } +func (t *EditFileTool) checkPermission(path string) string { + if !t.restrictToWorkspace || !t.askPermission || t.permissionCache == nil { + return "granted" + } + + if !t.isOutsideWorkspace(path) { + return "granted" + } + + if perm := t.permissionCache.Check(path); perm != "" { + if perm == "denied" { + return "denied" + } + return "granted" + } + + return "needs_permission" +} + func (t *EditFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult { path, ok := args["path"].(string) if !ok { @@ -105,16 +126,15 @@ func (t *EditFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe return ErrorResult("new_text is required") } - // Check permission for paths outside workspace - if t.askPermission && t.restrictToWorkspace && t.isOutsideWorkspace(path) { - if perm := t.permissionCache.Check(path); perm == "" { - return &ToolResult{ - ForLLM: fmt.Sprintf("Permission needed for path: %s. Call request_permission tool with path='%s'.", path, path), - ForUser: fmt.Sprintf("Permission required to edit %s", path), - } - } else if perm == "denied" { - return ErrorResult(fmt.Sprintf("Access to %s was denied", path)) + switch t.checkPermission(path) { + case "needs_permission": + logger.InfoCF("edit_file", "Permission needed", map[string]any{"path": path}) + return &ToolResult{ + ForLLM: fmt.Sprintf("Permission needed for path: %s. Call request_permission tool with path='%s'.", path, path), + ForUser: fmt.Sprintf("⚠️ Permission required to edit %s", path), } + case "denied": + return ErrorResult(fmt.Sprintf("Access to %s was denied", path)) } if err := editFile(t.fs, path, oldText, newText); err != nil { @@ -124,8 +144,11 @@ func (t *EditFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe } type AppendFileTool struct { - fs fileSystem - permissionCache interface{ Check(path string) string } + fs fileSystem + workspace string + restrictToWorkspace bool + permissionCache interface{ Check(path string) string } + askPermission bool } func NewAppendFileTool(workspace string, restrict bool, permCache any, allowPaths ...[]*regexp.Regexp) *AppendFileTool { @@ -133,10 +156,47 @@ func NewAppendFileTool(workspace string, restrict bool, permCache any, allowPath if len(allowPaths) > 0 { patterns = allowPaths[0] } - return &AppendFileTool{ - fs: buildFs(workspace, restrict, patterns), - permissionCache: permCache.(interface{ Check(path string) string }), + askPerm := false + var cache interface{ Check(path string) string } + if permCache != nil { + cache = permCache.(interface{ Check(path string) string }) + askPerm = true } + return &AppendFileTool{ + fs: buildFs(workspace, restrict, patterns), + workspace: workspace, + restrictToWorkspace: restrict, + permissionCache: cache, + askPermission: askPerm, + } +} + +func (t *AppendFileTool) isOutsideWorkspace(path string) bool { + if t.workspace == "" { + return false + } + absWorkspace, _ := filepath.Abs(t.workspace) + absPath, _ := filepath.Abs(path) + return !strings.HasPrefix(absPath, absWorkspace) +} + +func (t *AppendFileTool) checkPermission(path string) string { + if !t.restrictToWorkspace || !t.askPermission || t.permissionCache == nil { + return "granted" + } + + if !t.isOutsideWorkspace(path) { + return "granted" + } + + if perm := t.permissionCache.Check(path); perm != "" { + if perm == "denied" { + return "denied" + } + return "granted" + } + + return "needs_permission" } func (t *AppendFileTool) Name() string { @@ -170,6 +230,17 @@ func (t *AppendFileTool) Execute(ctx context.Context, args map[string]any) *Tool return ErrorResult("path is required") } + switch t.checkPermission(path) { + case "needs_permission": + logger.InfoCF("append_file", "Permission needed", map[string]any{"path": path}) + return &ToolResult{ + ForLLM: fmt.Sprintf("Permission needed for path: %s. Call request_permission tool with path='%s'.", path, path), + ForUser: fmt.Sprintf("⚠️ Permission required to append to %s", path), + } + case "denied": + return ErrorResult(fmt.Sprintf("Access to %s was denied", path)) + } + content, ok := args["content"].(string) if !ok { return ErrorResult("content is required") diff --git a/pkg/tools/fs/filesystem.go b/pkg/tools/fs/filesystem.go index ca057654b..00e807089 100644 --- a/pkg/tools/fs/filesystem.go +++ b/pkg/tools/fs/filesystem.go @@ -992,8 +992,11 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolR } type ListDirTool struct { - fs fileSystem - permissionCache interface{ Check(path string) string } + fs fileSystem + workspace string + restrictToWorkspace bool + permissionCache interface{ Check(path string) string } + askPermission bool } func NewListDirTool(workspace string, restrict bool, permCache any, allowPaths ...[]*regexp.Regexp) *ListDirTool { @@ -1001,10 +1004,47 @@ func NewListDirTool(workspace string, restrict bool, permCache any, allowPaths . if len(allowPaths) > 0 { patterns = allowPaths[0] } - return &ListDirTool{ - fs: buildFs(workspace, restrict, patterns), - permissionCache: permCache.(interface{ Check(path string) string }), + askPerm := false + var cache interface{ Check(path string) string } + if permCache != nil { + cache = permCache.(interface{ Check(path string) string }) + askPerm = true } + return &ListDirTool{ + fs: buildFs(workspace, restrict, patterns), + workspace: workspace, + restrictToWorkspace: restrict, + permissionCache: cache, + askPermission: askPerm, + } +} + +func (t *ListDirTool) isOutsideWorkspace(path string) bool { + if t.workspace == "" { + return false + } + absWorkspace, _ := filepath.Abs(t.workspace) + absPath, _ := filepath.Abs(path) + return !strings.HasPrefix(absPath, absWorkspace) +} + +func (t *ListDirTool) checkPermission(path string) string { + if !t.restrictToWorkspace || !t.askPermission || t.permissionCache == nil { + return "granted" + } + + if !t.isOutsideWorkspace(path) { + return "granted" + } + + if perm := t.permissionCache.Check(path); perm != "" { + if perm == "denied" { + return "denied" + } + return "granted" + } + + return "needs_permission" } func (t *ListDirTool) Name() string { @@ -1034,6 +1074,27 @@ func (t *ListDirTool) Execute(ctx context.Context, args map[string]any) *ToolRes path = "." } + // Resolve path for permission check (use workspace path for "." to check sandbox boundary) + checkPath := path + if path == "." { + if abs, err := filepath.Abs(path); err == nil { + checkPath = abs + } else if t.workspace != "" { + checkPath = t.workspace + } + } + + switch t.checkPermission(checkPath) { + case "needs_permission": + logger.InfoCF("list_dir", "Permission needed", map[string]any{"path": checkPath}) + return &ToolResult{ + ForLLM: fmt.Sprintf("Permission needed for path: %s. Call request_permission tool with path='%s'.", checkPath, checkPath), + ForUser: fmt.Sprintf("⚠️ Permission required to list %s", checkPath), + } + case "denied": + return ErrorResult(fmt.Sprintf("Access to %s was denied", checkPath)) + } + entries, err := t.fs.ReadDir(path) if err != nil { return ErrorResult(fmt.Sprintf("failed to read directory: %v", err)) diff --git a/web/backend/api/tools.go b/web/backend/api/tools.go index 3476e3c53..289ea5035 100644 --- a/web/backend/api/tools.go +++ b/web/backend/api/tools.go @@ -1,13 +1,17 @@ package api import ( + "bytes" "encoding/json" "fmt" + "io" "net/http" + "net/http/httputil" "runtime" "strings" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" picotools "github.com/sipeed/picoclaw/pkg/tools" ) @@ -196,6 +200,7 @@ func (h *Handler) registerToolRoutes(mux *http.ServeMux) { mux.HandleFunc("PUT /api/tools/{name}/state", h.handleUpdateToolState) mux.HandleFunc("GET /api/tools/web-search-config", h.handleGetWebSearchConfig) mux.HandleFunc("PUT /api/tools/web-search-config", h.handleUpdateWebSearchConfig) + mux.HandleFunc("POST /api/permission/grant", h.handleGrantPermission) } func (h *Handler) handleListTools(w http.ResponseWriter, r *http.Request) { @@ -672,3 +677,82 @@ func resolveCurrentWebSearchProvider(cfg *config.Config) string { } return selected } + +// handleGrantPermission proxies permission grant requests to the gateway's internal endpoint. +// Frontend sends POST /api/permission/grant with { "path": "...", "duration": "once"|"session" } +// We add the default agent ID ("main") and forward to the gateway's /internal/permission/grant. +// +// POST /api/permission/grant +func (h *Handler) handleGrantPermission(w http.ResponseWriter, r *http.Request) { + if !h.gatewayAvailableForProxy() { + logger.Warnf("Gateway not available for permission grant proxy") + http.Error(w, "Gateway not available", http.StatusServiceUnavailable) + return + } + + // Parse incoming request body from frontend + var req struct { + Path string `json:"path"` + Duration string `json:"duration"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + + // Validate required fields + if req.Path == "" || req.Duration == "" { + http.Error(w, "path and duration are required", http.StatusBadRequest) + return + } + if req.Duration != "once" && req.Duration != "session" { + http.Error(w, "duration must be 'once' or 'session'", http.StatusBadRequest) + return + } + + // Get gateway auth token from pid data + gateway.mu.Lock() + pidData := gateway.pidData + gateway.mu.Unlock() + + if pidData == nil || pidData.Token == "" { + logger.Warnf("Gateway auth token not available for permission grant") + http.Error(w, "Gateway auth token not available", http.StatusServiceUnavailable) + return + } + authToken := pidData.Token + + // Prepare request body for gateway's internal endpoint (requires agent_id) + grantReq := struct { + AgentID string `json:"agent_id"` + Path string `json:"path"` + Duration string `json:"duration"` + }{ + AgentID: "main", // Default to main agent + Path: req.Path, + Duration: req.Duration, + } + + // Create reverse proxy to gateway's internal permission grant endpoint + target := h.gatewayProxyURL() + target.Path = "/internal/permission/grant" + + proxy := &httputil.ReverseProxy{ + Rewrite: func(pr *httputil.ProxyRequest) { + pr.SetURL(target) + // Inject auth token for gateway health server + pr.Out.Header.Set("Authorization", "Bearer "+authToken) + // Replace request body with the modified payload that includes agent_id + bodyBytes, _ := json.Marshal(grantReq) + pr.Out.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + pr.Out.ContentLength = int64(len(bodyBytes)) + pr.Out.Header.Set("Content-Type", "application/json") + }, + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + logger.Errorf("Failed to proxy permission grant request: %v", err) + http.Error(w, "Gateway unavailable: "+err.Error(), http.StatusBadGateway) + }, + } + + proxy.ServeHTTP(w, r) +} diff --git a/web/frontend/.npmrc b/web/frontend/.npmrc new file mode 100644 index 000000000..5e4086a7f --- /dev/null +++ b/web/frontend/.npmrc @@ -0,0 +1 @@ +registry=https://registry.npmmirror.com/ diff --git a/web/frontend/src/api/tools.ts b/web/frontend/src/api/tools.ts index a77f3ba80..1aa2440c6 100644 --- a/web/frontend/src/api/tools.ts +++ b/web/frontend/src/api/tools.ts @@ -95,3 +95,26 @@ export async function updateWebSearchConfig( body: JSON.stringify(payload), }) } + +export interface GrantPermissionRequest { + path: string + command: string + duration: "once" | "session" +} + +export interface GrantPermissionResponse { + status: string + message?: string +} + +export async function grantPermission( + path: string, + command: string, + duration: "once" | "session" +): Promise { + return request("/api/permission/grant", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path, command, duration }), + }) +} diff --git a/web/frontend/src/components/permission/PermissionPrompt.tsx b/web/frontend/src/components/permission/PermissionPrompt.tsx new file mode 100644 index 000000000..d2e6c1c3c --- /dev/null +++ b/web/frontend/src/components/permission/PermissionPrompt.tsx @@ -0,0 +1,111 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { grantPermission } from "@/api/tools"; +import { toast } from "sonner"; + +interface PermissionPromptProps { + onPermissionGranted: (duration: "once" | "session") => void; + onPermissionDenied: () => void; + toolName: string; + path: string; + originalCommand: string; +} + +export function PermissionPrompt({ + onPermissionGranted, + onPermissionDenied, + toolName, + path, + originalCommand, +}: PermissionPromptProps) { + const { t } = useTranslation(); + const [isLoading, setIsLoading] = useState(false); + + const handlePermissionResponse = async (response: "once" | "session" | "no") => { + setIsLoading(true); + try { + if (response === "no") { + onPermissionDenied(); + toast.error(t("permissionDenied", { path })); + return; + } + + // Grant permission via API + await grantPermission(path, originalCommand, response); + + // Notify the caller to update state + onPermissionGranted(response); + toast.success( + response === "once" + ? t("permissionGrantedOnce", { path }) + : t("permissionGrantedSession", { path }) + ); + } catch (error) { + console.error("Permission error:", error); + toast.error(t("permissionError")); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+
+ {/* Warning icon */} +
+ + + +
+
+
+

{t("permissionRequiredTitle")}

+

+ {t("permissionRequiredMessage", { + tool: toolName, + path, + })} +

+ {originalCommand && ( +
+

{t("commandToExecute")}

+
+                  {originalCommand}
+                
+
+ )} +
+
+
+
+ + + +
+
+ ); +} \ No newline at end of file diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 634e509a2..fa08e1195 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -50,6 +50,16 @@ "websocketDisconnected": "Unable to chat: WebSocket connection is disconnected. Check network and gateway status, then refresh the page or restart Launcher.", "websocketError": "Unable to chat: WebSocket connection failed. Check network and gateway status, then retry.", "noDefaultModel": "Unable to chat: No default model is selected. Set a default model on the Models page." + "permissionRequiredTitle": "Permission Required", + "permissionRequiredMessage": "PicoClaw wants to use {{tool}} to access {{path}} which is outside your workspace.", + "commandToExecute": "Command to execute:", + "permissionDeny": "Deny", + "permissionOnce": "Allow once", + "permissionSession": "Allow for session", + "permissionGrantedOnce": "Permission granted for one-time access to {{path}}", + "permissionGrantedSession": "Permission granted for session access to {{path}}", + "permissionDenied": "Permission denied for {{path}}", + "permissionError": "An error occurred while handling the permission request.", }, "newChat": "New Chat", "notConnected": "Gateway is not running. Start it to chat.", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 3cd6f6c54..5b41f4f78 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -50,6 +50,16 @@ "websocketDisconnected": "无法对话:WebSocket 连接已断开。请检查网络与服务状态,然后刷新页面或重启 Launcher。", "websocketError": "无法对话:WebSocket 连接失败。请检查网络与服务状态后重试。", "noDefaultModel": "无法对话:尚未设置默认模型。请前往模型页面设置默认模型。" + "permissionRequiredTitle": "需要权限", + "permissionRequiredMessage": "PicoClaw 想要使用 {{tool}} 访问 {{path}},但该路径位于工作区之外。", + "commandToExecute": "要执行的命令:", + "permissionDeny": "拒绝", + "permissionOnce": "仅允许一次", + "permissionSession": "允许整个会话", + "permissionGrantedOnce": "已授予对 {{path}} 的一次性访问权限", + "permissionGrantedSession": "已授予对 {{path}} 的会话访问权限", + "permissionDenied": "已拒绝对 {{path}} 的访问权限", + "permissionError": "处理权限请求时发生错误。", }, "newChat": "新建对话", "notConnected": "服务未运行,请先启动以进行对话。",