| 1 | import { app } from "./bridge"; |
| 2 | import { pathExtension } from "./filePaths"; |
| 3 | |
| 4 | /** Where a file reference came from: an agent presentation, the workspace, or verified answer text. */ |
| 5 | export type FileResourceSource = "presented" | "workspace" | "reference"; |
| 6 | |
| 7 | type ResourceBase = { hostId: string; tabId: string; path: string }; |
| 8 | |
| 9 | /** What a caller knows about a file: host, session, path, origin and tool call. */ |
| 10 | export type FileResourceRef = |
| 11 | | (ResourceBase & { source: "presented"; toolCallId: string }) |
| 12 | | (ResourceBase & { source: "workspace"; toolCallId?: string }) |
| 13 | // An answer-named path has no standing authorization. Every read and action |
| 14 | // goes through the reference endpoints, which resolve it again for this tab. |
| 15 | | (ResourceBase & { source: "reference" }); |
| 16 | |
| 17 | /** |
| 18 | * The credentials a single read must present. Captured from the command that |
| 19 | * asks for the read and never inherited by a later entry point: a workspace |
| 20 | * reference carries no presented tool scope even when the same path was |
| 21 | * presented earlier, so a path alone cannot re-grant an earlier presentation. |
| 22 | */ |
| 23 | export type FileAccessContext = Readonly<{ |
| 24 | source: FileResourceSource; |
| 25 | /** Session tab whose scope authorizes the read. */ |
| 26 | tabId: string; |
| 27 | /** Presented tool call; absent for workspace reads. */ |
| 28 | toolCallId?: string; |
| 29 | }>; |
| 30 | |
| 31 | /** A file the backend entry point confirmed, with the context that reads it. */ |
| 32 | export type ResolvedFileResource = Readonly<{ |
| 33 | hostId: string; |
| 34 | /** Path the read entry points accept. */ |
| 35 | path: string; |
| 36 | /** Stable backend-resolved coordinate used only for resource identity. */ |
| 37 | identityPath: string; |
| 38 | /** Path as the caller supplied it, for display and tree reveal. */ |
| 39 | requestedPath: string; |
| 40 | access: FileAccessContext; |
| 41 | }>; |
| 42 | |
| 43 | export function fileAccessContext(ref: FileResourceRef): FileAccessContext { |
| 44 | if (ref.source === "presented") return { source: "presented", tabId: ref.tabId, toolCallId: ref.toolCallId }; |
| 45 | return { source: ref.source, tabId: ref.tabId }; |
| 46 | } |
| 47 | |
| 48 | export const sameAccessContext = (left: FileAccessContext, right: FileAccessContext): boolean => |
| 49 | left.source === right.source && left.tabId === right.tabId && left.toolCallId === right.toolCallId; |
| 50 | |
| 51 | /** |
| 52 | * Resolve a caller spelling into the stable coordinate that identifies the |
| 53 | * file. The original local spelling remains the read path because it may be an |
| 54 | * external-folder token or a presented path whose access must be revalidated by |
| 55 | * its scoped read entry point. Remote reads accept the resolved host coordinate. |
| 56 | */ |
| 57 | export async function resolveFileResource(ref: FileResourceRef): Promise<ResolvedFileResource> { |
| 58 | const access = fileAccessContext(ref); |
| 59 | const identityPath = await resolveFileResourcePath(ref); |
| 60 | return { |
| 61 | hostId: ref.hostId, |
| 62 | path: ref.hostId === "local" ? ref.path : identityPath, |
| 63 | identityPath, |
| 64 | requestedPath: ref.path, |
| 65 | access, |
| 66 | }; |
| 67 | } |
| 68 | |
| 69 | /** Absolute path for copy-to-clipboard and save-a-copy, where display needs one. */ |
| 70 | export function resolveFileResourcePath(ref: FileResourceRef): Promise<string> { |
| 71 | if (ref.hostId !== "local") { |
| 72 | if (ref.source === "reference") return Promise.resolve(ref.path); |
| 73 | return ref.source === "presented" |
| 74 | ? app.ResolveRemotePresentedPathForTab(ref.tabId, ref.hostId, ref.toolCallId, ref.path) |
| 75 | : app.ResolveRemoteWorkspacePathForTab(ref.tabId, ref.hostId, ref.toolCallId ?? "", ref.path); |
| 76 | } |
| 77 | if (ref.source === "reference") return app.ResolveReferencePathForTab(ref.tabId, ref.path); |
| 78 | return ref.source === "presented" |
| 79 | ? app.ResolvePresentedPathForTab(ref.tabId, ref.toolCallId, ref.path) |
| 80 | : app.ResolveWorkspacePathForTab(ref.tabId, ref.path); |
| 81 | } |
| 82 | |
| 83 | const MEDIA = new Set(["html", "htm", "pdf", "png", "jpg", "jpeg", "gif", "webp", "bmp", "ico", "svg", "mp3", "wav", "ogg", "m4a", "aac", "mp4", "webm", "mov", "m4v", "ogv"]); |
| 84 | // SVG is an image to the previewer and a document to the editor, so both views |
| 85 | // are legitimate. |
| 86 | const BINARY = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico", "pdf", "mp3", "wav", "ogg", "m4a", "aac", "flac", "mp4", "webm", "mov", "m4v", "ogv", "zip", "tar", "gz", "7z", "rar", "doc", "docx", "xls", "xlsx", "ppt", "pptx"]); |
| 87 | |
| 88 | export interface FileResourceCapabilities { |
| 89 | preview: boolean; |
| 90 | source: boolean; |
| 91 | browser: boolean; |
| 92 | revealTree: boolean; |
| 93 | copyPath: boolean; |
| 94 | openNative: boolean; |
| 95 | revealNative: boolean; |
| 96 | saveCopy: boolean; |
| 97 | } |
| 98 | |
| 99 | /** Identity-only view of a file: capabilities depend on the host and the name. */ |
| 100 | export type FileResourceIdentity = Readonly<{ hostId: string; path: string }>; |
| 101 | |
| 102 | export const fileResourceIdentity = (resource: FileResourceIdentity): FileResourceIdentity => |
| 103 | ({ hostId: resource.hostId, path: resource.path }); |
| 104 | |
| 105 | /** Maps the host's verified action list onto the menu capability shape. */ |
| 106 | export function capabilityActions(actions: readonly string[]): FileResourceCapabilities { |
| 107 | const has = (action: string) => actions.includes(action); |
| 108 | return { |
| 109 | preview: has("preview"), |
| 110 | source: has("source"), |
| 111 | browser: has("browser"), |
| 112 | revealTree: has("reveal-tree"), |
| 113 | copyPath: has("copy-path"), |
| 114 | openNative: has("open-native"), |
| 115 | revealNative: has("reveal-native"), |
| 116 | saveCopy: has("save-copy"), |
| 117 | }; |
| 118 | } |
| 119 | |
| 120 | /** The host still revalidates every action; this snapshot only controls honest UI affordances. */ |
| 121 | export function fileResourceCapabilities(resource: FileResourceIdentity, verified?: readonly string[]): FileResourceCapabilities { |
| 122 | if (verified) return capabilityActions(verified); |
| 123 | const remote = resource.hostId !== "local"; |
| 124 | const ext = pathExtension(resource.path); |
| 125 | return { |
| 126 | preview: true, |
| 127 | source: !BINARY.has(ext), |
| 128 | browser: !remote && MEDIA.has(ext), |
| 129 | revealTree: true, |
| 130 | copyPath: true, |
| 131 | openNative: !remote, |
| 132 | revealNative: !remote, |
| 133 | saveCopy: true, |
| 134 | }; |
| 135 | } |
| 136 |