返回 DeepSeek-Reasonix
chat_file_reference.go
根目录 / desktop / chat_file_reference.go
1 package main
2
3 import (
4 "errors"
5 "os"
6 "path/filepath"
7 "strings"
8 )
9
10 // Chat file references let the renderer submit answer-named paths for host
11 // verification. Resolution reads no content and every action revalidates the
12 // path, because one successful lookup is not a standing authorization.
13
14 const (
15 // chatFileReferenceBatchLimit caps one request; the renderer splits larger
16 // sets. chatFileReferenceMaxChars keeps a pathological candidate from
17 // becoming a path lookup.
18 chatFileReferenceBatchLimit = 64
19 chatFileReferenceMaxChars = 4096
20 )
21
22 // ChatFileReferenceRequest is one deduplicated renderer candidate. Key is the
23 // renderer's own identity for the candidate and is echoed back untouched.
24 type ChatFileReferenceRequest struct {
25 Key string `json:"key"`
26 Path string `json:"path"`
27 }
28
29 // ChatFileReference is the per-candidate verdict. Actions is always present so
30 // the menu never has to guess an affordance from a file extension.
31 type ChatFileReference struct {
32 Key string `json:"key"`
33 Path string `json:"path"`
34 Status string `json:"status"`
35 DisplayPath string `json:"displayPath,omitempty"`
36 Kind string `json:"kind,omitempty"`
37 Actions []string `json:"actions"`
38 Reason string `json:"reason,omitempty"`
39 }
40
41 // ChatFileReferenceResult echoes the turn the renderer asked about, so a late
42 // response can be rejected against the answer still on screen.
43 type ChatFileReferenceResult struct {
44 TurnKey string `json:"turnKey"`
45 References []ChatFileReference `json:"references"`
46 }
47
48 // ResolveChatFileReferencesForTab verifies answer-named paths against the
49 // session bound to tabID. The renderer never supplies a working directory or a
50 // host platform; the host decides both.
51 func (a *App) ResolveChatFileReferencesForTab(tabID, turnKey string, candidates []ChatFileReferenceRequest) ChatFileReferenceResult {
52 out := ChatFileReferenceResult{TurnKey: turnKey, References: make([]ChatFileReference, 0, len(candidates))}
53 if len(candidates) > chatFileReferenceBatchLimit {
54 candidates = candidates[:chatFileReferenceBatchLimit]
55 }
56 for _, candidate := range candidates {
57 out.References = append(out.References, a.resolveChatFileReference(tabID, candidate))
58 }
59 return out
60 }
61
62 func (a *App) resolveChatFileReference(tabID string, candidate ChatFileReferenceRequest) ChatFileReference {
63 ref := ChatFileReference{Key: candidate.Key, Path: candidate.Path, Actions: []string{}}
64 requested := strings.TrimSpace(candidate.Path)
65 switch {
66 case requested == "":
67 return chatReferenceFailure(ref, "unsupported", "invalid")
68 case len(requested) > chatFileReferenceMaxChars:
69 // The candidate stays on screen as plain text; only its link is refused.
70 return chatReferenceFailure(ref, "unsupported", "too-long")
71 }
72 resolved, display, reason := a.chatReferencePathForTab(tabID, requested)
73 if reason != "" {
74 status := "unavailable"
75 if reason == "invalid" || reason == "not-a-file" {
76 status = "unsupported"
77 }
78 return chatReferenceFailure(ref, status, reason)
79 }
80 kind, mime := previewMediaKind(resolved)
81 ref.Status = "resolved"
82 ref.DisplayPath = display
83 ref.Kind = kind
84 ref.Actions = chatReferenceActions(kind, mime, true)
85 return ref
86 }
87
88 func chatReferenceFailure(ref ChatFileReference, status, reason string) ChatFileReference {
89 ref.Status = status
90 ref.Reason = reason
91 return ref
92 }
93
94 // chatReferencePathForTab resolves and authorizes one candidate. There is no
95 // trusted `present` declaration to match, so the file must already live inside
96 // the session workspace or inside a directory the user registered for this
97 // session — matching the read surface the file tree itself exposes.
98 //
99 // The returned display path stays in the same path space the dock already
100 // navigates: workspace-relative for files inside the workspace, absolute for an
101 // authorized external folder.
102 func (a *App) chatReferencePathForTab(tabID, candidate string) (resolved, display, reason string) {
103 root, ctrl, ok := a.workspaceTargetForTab(tabID)
104 if !ok {
105 return "", "", "unknown-session"
106 }
107 source, err := localChatPathSource(candidate)
108 if err != nil {
109 if errors.Is(err, os.ErrPermission) {
110 return "", "", "outside-workspace"
111 }
112 return "", "", "invalid"
113 }
114 if browser := externalFolderRefBrowserFromController(ctrl); browser != nil {
115 if path, refDisplay, found := browser.ExternalFolderRefLocalPath(source); found {
116 return validateChatReferencePath(root, path, refDisplay)
117 }
118 }
119 if filepath.IsAbs(source) {
120 if authorizer, ok := ctrl.(interface {
121 AuthorizedExternalFolderLocalPath(string) (string, bool)
122 }); ok {
123 if external, allowed := authorizer.AuthorizedExternalFolderLocalPath(source); allowed {
124 return validateChatReferencePath(root, external, filepath.ToSlash(external))
125 }
126 }
127 }
128 base, err := workspaceBaseFromRoot(root)
129 if err != nil {
130 return "", "", "unknown-session"
131 }
132 joined, inside, err := workspacePathForBase(base, source)
133 if err != nil || !inside {
134 return "", "", "outside-workspace"
135 }
136 // The lexical join is not containment: a symlinked component can still
137 // leave the workspace, so compare the real locations.
138 contained, realBase, err := canonicalPathWithin(base, joined)
139 if err != nil {
140 if os.IsNotExist(err) {
141 return "", "", "not-found"
142 }
143 return "", "", "outside-workspace"
144 }
145 relative, err := filepath.Rel(realBase, contained)
146 if err != nil {
147 return "", "", "outside-workspace"
148 }
149 return validateChatReferencePath(root, contained, filepath.ToSlash(relative))
150 }
151
152 func validateChatReferencePath(workspaceRoot, resolved, display string) (string, string, string) {
153 info, err := os.Lstat(resolved)
154 if err != nil {
155 if os.IsNotExist(err) {
156 return "", "", "not-found"
157 }
158 return "", "", "unreadable"
159 }
160 // Containment is already proven on the real path. Directories, devices, and
161 // other non-regular targets have no preview action.
162 if !info.Mode().IsRegular() {
163 return "", "", "not-a-file"
164 }
165 if !readPolicyAllowsPath(workspaceRoot, resolved) {
166 return "", "", "blocked"
167 }
168 return resolved, display, ""
169 }
170
171 // chatReferenceActions lists what the host will actually perform for this file.
172 // SVG and HTML are text formats: they preview as images or pages but must still
173 // offer the source view, which is the one affordance an extension guess gets
174 // wrong. A reference has no built-in browser preview of its own, so no browser
175 // action is advertised.
176 func chatReferenceActions(kind, mime string, local bool) []string {
177 actions := []string{"preview", "reveal-tree", "copy-path", "save-copy"}
178 if kind == "" || mime == "image/svg+xml" || strings.HasPrefix(mime, "text/html") {
179 actions = append(actions, "source")
180 }
181 if local {
182 actions = append(actions, "open-native", "reveal-native")
183 }
184 return actions
185 }
186
187 // ReadReferenceFileForTab reads a verified answer reference. The path is
188 // re-resolved on every call, so a deleted file, a changed permission, or a
189 // replaced session surfaces as a local error instead of falling back to another
190 // file.
191 func (a *App) ReadReferenceFileForTab(tabID, path string) FilePreview {
192 resolved, display, reason := a.chatReferencePathForTab(tabID, path)
193 if reason != "" {
194 return FilePreview{Path: path, Err: reason}
195 }
196 return a.readFilePathForTab(tabID, display, resolved, false)
197 }
198
199 func (a *App) ReadReferenceFileSourceForTab(tabID, path string) FilePreview {
200 resolved, display, reason := a.chatReferencePathForTab(tabID, path)
201 if reason != "" {
202 return FilePreview{Path: path, Err: reason}
203 }
204 return a.readFilePathForTab(tabID, display, resolved, true)
205 }
206
207 // ResolveReferencePathForTab returns the absolute path for display and copy
208 // actions, after the same re-validation every other reference action performs.
209 func (a *App) ResolveReferencePathForTab(tabID, path string) (string, error) {
210 resolved, _, reason := a.chatReferencePathForTab(tabID, path)
211 if reason != "" {
212 return "", chatReferenceError(reason)
213 }
214 return resolved, nil
215 }
216
217 func (a *App) OpenReferencePathForTab(tabID, path string) error {
218 resolved, _, reason := a.chatReferencePathForTab(tabID, path)
219 if reason != "" {
220 return chatReferenceError(reason)
221 }
222 return openWorkspacePath(resolved)
223 }
224
225 func (a *App) RevealReferencePathForTab(tabID, path string) error {
226 resolved, _, reason := a.chatReferencePathForTab(tabID, path)
227 if reason != "" {
228 return chatReferenceError(reason)
229 }
230 return revealPath(resolved)
231 }
232
233 func (a *App) SaveReferencePathAsForTab(tabID, path string) (string, error) {
234 resolved, _, reason := a.chatReferencePathForTab(tabID, path)
235 if reason != "" {
236 return "", chatReferenceError(reason)
237 }
238 return a.SaveLocalPathAs(resolved)
239 }
240
241 // chatReferenceError is the caller-facing form of a reason code. The renderer
242 // shows its own localized text for the codes it recognizes.
243 func chatReferenceError(reason string) error {
244 switch reason {
245 case "not-found":
246 return os.ErrNotExist
247 case "outside-workspace", "blocked":
248 return os.ErrPermission
249 default:
250 return os.ErrInvalid
251 }
252 }
253
253 lines GO