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.
This commit is contained in:
parent
9870f46180
commit
1fc04f7c77
13 changed files with 516 additions and 26 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
1
web/frontend/.npmrc
Normal file
1
web/frontend/.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
registry=https://registry.npmmirror.com/
|
||||
|
|
@ -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<GrantPermissionResponse> {
|
||||
return request<GrantPermissionResponse>("/api/permission/grant", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path, command, duration }),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
111
web/frontend/src/components/permission/PermissionPrompt.tsx
Normal file
111
web/frontend/src/components/permission/PermissionPrompt.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="bg-border/10 border border-border/20 rounded-lg p-4 mb-4">
|
||||
<div className="mb-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
{/* Warning icon */}
|
||||
<div className="h-8 w-8 flex items-center justify-center bg-yellow-100 text-yellow-800 rounded-full">
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-yellow-800">{t("permissionRequiredTitle")}</h3>
|
||||
<p className="mt-1 text-sm text-yellow-700">
|
||||
{t("permissionRequiredMessage", {
|
||||
tool: toolName,
|
||||
path,
|
||||
})}
|
||||
</p>
|
||||
{originalCommand && (
|
||||
<div className="mt-2">
|
||||
<p className="font-medium text-yellow-800">{t("commandToExecute")}</p>
|
||||
<pre className="mt-1 bg-yellow-50 p-2 rounded text-xs font-mono break-all">
|
||||
{originalCommand}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePermissionResponse("no")}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t("permissionDeny")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handlePermissionResponse("once")}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t("permissionOnce")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => handlePermissionResponse("session")}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t("permissionSession")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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": "服务未运行,请先启动以进行对话。",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue