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:
Dark aura 2026-05-06 03:15:59 +01:00
parent 9870f46180
commit 1fc04f7c77
13 changed files with 516 additions and 26 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
View file

@ -0,0 +1 @@
registry=https://registry.npmmirror.com/

View file

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

View 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>
);
}

View file

@ -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.",

View file

@ -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": "服务未运行,请先启动以进行对话。",