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