From b1ea4d613cc3f2b03ada2320da44f3317b5fc440 Mon Sep 17 00:00:00 2001 From: Asaf Benatia Date: Fri, 21 Aug 2026 06:29:21 +0300 Subject: [PATCH 1/5] feat(composer): drop non-image files as filesystem paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dropping an audio file, PDF or video onto the composer was rejected with 'Please attach image files only'. Images still become attachments; everything else now inserts its filesystem path as text, so the agent opens the file from disk itself and a large recording never crosses the wire — the same thing dropping a file into a terminal does. The renderer cannot read a dropped file's path (Electron removed File.path in v32), so preload exposes webUtils.getPathForFile through the desktop bridge. In a browser tab, where no path exists, the drop says so instead of failing silently. Note the deliberate absence of focusComposer() on the path branch: focusing synchronously after the insert makes the not-yet-reconciled Lexical editor sync its stale empty state back over the text, which is the same footgun makeComposerMentionDragHandlers documents for the file-tree mention drop. --- apps/desktop/src/preload.ts | 11 +++- apps/web/src/components/chat/ChatComposer.tsx | 56 ++++++++++++++++++- .../components/chat/droppedFilePaths.test.ts | 29 ++++++++++ .../src/components/chat/droppedFilePaths.ts | 31 ++++++++++ packages/contracts/src/ipc.ts | 7 +++ 5 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/chat/droppedFilePaths.test.ts create mode 100644 apps/web/src/components/chat/droppedFilePaths.ts diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index d1313ff2e767..f6cd60dded19 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -5,7 +5,7 @@ import type { DesktopPreviewTabState, } from "@t3tools/contracts"; import { exposeClerkBridge } from "@clerk/electron/preload"; -import { contextBridge, ipcRenderer } from "electron"; +import { contextBridge, ipcRenderer, webUtils } from "electron"; import * as IpcChannels from "./ipc/channels.ts"; @@ -39,6 +39,15 @@ contextBridge.exposeInMainWorld("desktopBridge", { return result as ReturnType; }, getClientPlatform: () => clientPlatform, + getPathForFile: (file: File) => { + // Throws for a File that never came from the OS (e.g. built by the page). + try { + const path = webUtils.getPathForFile(file); + return path.length > 0 ? path : null; + } catch { + return null; + } + }, getSystemLocale: () => { const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL); return typeof result === "string" ? result : null; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 8937e52048e7..2def9b33c61a 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -169,6 +169,7 @@ import { submitComposerDraft, } from "./composerSubmission"; import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; +import { formatDroppedFilePaths } from "./droppedFilePaths"; function ComposerVideoThumbnail({ file }: { file: File }) { const setVideo = useCallback( @@ -3148,6 +3149,37 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) void addComposerAttachments(files); }; + /** + * Insert dropped non-image files as filesystem paths. Only the desktop app + * can resolve a path from a dropped File, so in a browser tab this reports + * why nothing was inserted instead of silently swallowing the drop. + */ + const insertDroppedFilePaths = (files: File[]) => { + const resolvePath = window.desktopBridge?.getPathForFile; + if (!resolvePath) { + setThreadError( + activeThreadId, + "Attaching files by path needs the desktop app. Paste an image, or type the path.", + ); + return; + } + const paths = files + .map((file) => resolvePath(file)) + .filter((path): path is string => path !== null); + const text = formatDroppedFilePaths(paths); + if (text.length === 0) { + setThreadError(activeThreadId, "Could not read the location of the dropped file(s)."); + return; + } + if (!insertComposerTextAtEnd(text, { ensureLeadingBoundary: true })) { + toastManager.add({ + type: "error", + title: "Unable to add to chat", + description: "The composer is busy; try again once it is ready.", + }); + } + }; + const insertComposerTextAtEnd = ( text: string, options?: { ensureLeadingBoundary?: boolean }, @@ -3271,7 +3303,29 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerEditorRef.current?.focusAt(cursor); }, addDroppedFiles: (files: File[]) => { - void addComposerAttachments(files); + // The composer attaches what it can render inline — images and video. + // Everything else (audio, PDF, archives, …) is handed over as a path so + // the agent opens it from disk itself, instead of being rejected as "not + // a supported image type" or pushing a 40MB recording across the wire. + const attachable = files.filter( + (file) => + file.type.startsWith("image/") || + videoMimeType({ name: file.name, mimeType: file.type }) !== undefined, + ); + const byPath = files.filter((file) => !attachable.includes(file)); + if (attachable.length > 0) { + void addComposerAttachments(attachable); + } + if (byPath.length > 0) { + // Deliberately no focusComposer() on this path. `applyPromptReplacement` + // focuses on the next frame, once Lexical has reconciled; focusing + // synchronously here makes the not-yet-reconciled editor sync its stale + // empty state back over the text we just inserted, so the drop looks + // like it silently did nothing. Same footgun the file-tree mention drop + // documents in makeComposerMentionDragHandlers. + insertDroppedFilePaths(byPath); + return; + } focusComposer(); }, insertTextAtEnd: insertComposerTextAtEnd, diff --git a/apps/web/src/components/chat/droppedFilePaths.test.ts b/apps/web/src/components/chat/droppedFilePaths.test.ts new file mode 100644 index 000000000000..7030a4c93a97 --- /dev/null +++ b/apps/web/src/components/chat/droppedFilePaths.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { formatDroppedFilePaths, quoteDroppedFilePath } from "./droppedFilePaths"; + +describe("quoteDroppedFilePath", () => { + it("leaves a path without whitespace alone", () => { + expect(quoteDroppedFilePath("/Users/me/notes.pdf")).toBe("/Users/me/notes.pdf"); + }); + + it("quotes a path containing spaces", () => { + expect(quoteDroppedFilePath("/Users/me/Voice Memos/note 1.m4a")).toBe( + '"/Users/me/Voice Memos/note 1.m4a"', + ); + }); +}); + +describe("formatDroppedFilePaths", () => { + it("joins several paths with a space", () => { + expect(formatDroppedFilePaths(["/a/one.opus", "/b/two.pdf"])).toBe("/a/one.opus /b/two.pdf"); + }); + + it("skips empty and whitespace-only entries", () => { + expect(formatDroppedFilePaths(["", " ", "/a/one.opus"])).toBe("/a/one.opus"); + }); + + it("returns an empty string when nothing resolved", () => { + expect(formatDroppedFilePaths([])).toBe(""); + }); +}); diff --git a/apps/web/src/components/chat/droppedFilePaths.ts b/apps/web/src/components/chat/droppedFilePaths.ts new file mode 100644 index 000000000000..a3fd0724c594 --- /dev/null +++ b/apps/web/src/components/chat/droppedFilePaths.ts @@ -0,0 +1,31 @@ +/** + * Files dropped from the OS that aren't images are handed to the agent by + * *path*, not by content: it can open the file itself, so a 40MB recording or + * a PDF never has to cross the wire as an attachment. This mirrors dropping a + * file into a terminal, where the shell receives the path. + * + * Paths are only available in the desktop app (Electron's `webUtils`); in a + * browser tab the File object carries no filesystem path at all. + */ + +/** + * Quote a path for the prompt when whitespace would make where it ends + * ambiguous. This is prompt text, not a shell command — the goal is a clear + * boundary for the reader, not shell-injection safety. + */ +export function quoteDroppedFilePath(path: string): string { + return /\s/.test(path) ? `"${path}"` : path; +} + +/** + * The text inserted into the composer for a set of dropped paths. Empty and + * whitespace-only paths are dropped (a bridge that can't resolve a file + * returns null, which the caller filters, but be defensive about "" too). + */ +export function formatDroppedFilePaths(paths: ReadonlyArray): string { + return paths + .map((path) => path.trim()) + .filter((path) => path.length > 0) + .map(quoteDroppedFilePath) + .join(" "); +} diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b78479278330..112adf297682 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1091,6 +1091,13 @@ export interface DesktopBridge { * regardless of OS settings. */ getSystemLocale?: () => string | null; + /** + * The filesystem path of a dropped/pasted `File`, which the renderer cannot + * read for itself (Electron removed `File.path` in v32). Returns null when + * the object has no path — e.g. a file synthesised in-page rather than + * dragged in from the OS. + */ + getPathForFile?: (file: File) => string | null; // One bootstrap per pool instance currently registered with bootstrap // info (omits instances whose backend hasn't produced a config yet). // The primary backend is identified by id === PRIMARY_LOCAL_ENVIRONMENT_ID. From 6fbc859d4c99cf00642ee15708c13c3061a2e420 Mon Sep 17 00:00:00 2001 From: Asaf Benatia Date: Fri, 21 Aug 2026 06:42:18 +0300 Subject: [PATCH 2/5] fix(composer): never alter a dropped path while formatting it Two ways the formatter could hand the agent a path that does not exist: - trimming every entry destroyed a leading or trailing space, which is legal in a POSIX filename ('/tmp/report ' became '/tmp/report'). Emptiness is now tested on a trimmed copy while the original path is what gets inserted. - a double quote is also legal in a filename, so wrapping '/tmp/a " b.pdf' in quotes made the inner quote read as the closing delimiter. Inner quotes are escaped before wrapping. --- .../components/chat/droppedFilePaths.test.ts | 12 ++++++++++++ .../web/src/components/chat/droppedFilePaths.ts | 17 ++++++++++++----- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/droppedFilePaths.test.ts b/apps/web/src/components/chat/droppedFilePaths.test.ts index 7030a4c93a97..b07c9b23fb6d 100644 --- a/apps/web/src/components/chat/droppedFilePaths.test.ts +++ b/apps/web/src/components/chat/droppedFilePaths.test.ts @@ -7,6 +7,14 @@ describe("quoteDroppedFilePath", () => { expect(quoteDroppedFilePath("/Users/me/notes.pdf")).toBe("/Users/me/notes.pdf"); }); + it("escapes a double quote inside a quoted path", () => { + expect(quoteDroppedFilePath('/tmp/a " b.pdf')).toBe('"/tmp/a \\" b.pdf"'); + }); + + it("leaves a double quote alone when there is no whitespace to quote for", () => { + expect(quoteDroppedFilePath('/tmp/a"b.pdf')).toBe('/tmp/a"b.pdf'); + }); + it("quotes a path containing spaces", () => { expect(quoteDroppedFilePath("/Users/me/Voice Memos/note 1.m4a")).toBe( '"/Users/me/Voice Memos/note 1.m4a"', @@ -19,6 +27,10 @@ describe("formatDroppedFilePaths", () => { expect(formatDroppedFilePaths(["/a/one.opus", "/b/two.pdf"])).toBe("/a/one.opus /b/two.pdf"); }); + it("preserves a trailing space in a filename instead of trimming it", () => { + expect(formatDroppedFilePaths(["/tmp/report "])).toBe('"/tmp/report "'); + }); + it("skips empty and whitespace-only entries", () => { expect(formatDroppedFilePaths(["", " ", "/a/one.opus"])).toBe("/a/one.opus"); }); diff --git a/apps/web/src/components/chat/droppedFilePaths.ts b/apps/web/src/components/chat/droppedFilePaths.ts index a3fd0724c594..c8a70fa6ed7d 100644 --- a/apps/web/src/components/chat/droppedFilePaths.ts +++ b/apps/web/src/components/chat/droppedFilePaths.ts @@ -14,18 +14,25 @@ * boundary for the reader, not shell-injection safety. */ export function quoteDroppedFilePath(path: string): string { - return /\s/.test(path) ? `"${path}"` : path; + if (!/\s/.test(path)) { + return path; + } + // A double quote is legal in a POSIX filename, so escape any before wrapping — + // otherwise the first inner quote reads as the closing delimiter. + return `"${path.replace(/"/g, '\\"')}"`; } /** - * The text inserted into the composer for a set of dropped paths. Empty and - * whitespace-only paths are dropped (a bridge that can't resolve a file + * The text inserted into the composer for a set of dropped paths. Entries that + * are empty or all whitespace are skipped (a bridge that can't resolve a file * returns null, which the caller filters, but be defensive about "" too). + * A path that survives is never altered: leading and trailing spaces are legal + * in a filename, so trimming one would point the agent at a file that does not + * exist. Quoting keeps such a path readable instead. */ export function formatDroppedFilePaths(paths: ReadonlyArray): string { return paths - .map((path) => path.trim()) - .filter((path) => path.length > 0) + .filter((path) => path.trim().length > 0) .map(quoteDroppedFilePath) .join(" "); } From 2d705b98e93e08af4362da9be628391f7bfed4b4 Mon Sep 17 00:00:00 2001 From: Asaf Benatia Date: Fri, 21 Aug 2026 06:54:58 +0300 Subject: [PATCH 3/5] fix(composer): keep a path failure from speaking for the whole drop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mixed drop (images + other files) reported path failures through setThreadError, the single banner the image attach path also writes: a successful image attach alongside an unsupported path read as though the whole drop had failed. Path failures now go to a toast, which coexists with the banner, and the 'composer is busy' refusal is suppressed when images were in the same drop, since that refusal comes from the state that already rejected them — one drop never says it twice. insertDroppedFilePaths now reports whether it inserted, so focusComposer() is skipped only when text actually landed. The stale-state rationale applies to a successful insert; on failure there is nothing to lose and focus behaves as it always did. --- apps/web/src/components/chat/ChatComposer.tsx | 55 +++++++++++-------- 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 2def9b33c61a..a097238e6924 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -3150,34 +3150,41 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }; /** - * Insert dropped non-image files as filesystem paths. Only the desktop app - * can resolve a path from a dropped File, so in a browser tab this reports - * why nothing was inserted instead of silently swallowing the drop. + * Insert dropped non-image files as filesystem paths, reporting to a toast + * rather than the thread error banner: a mixed drop's images own that banner, + * and a path failure there would read as though the whole drop failed. Only + * the desktop app can resolve a path from a dropped File, so in a browser tab + * this says so instead of silently swallowing the drop. Returns whether text + * was actually inserted. */ - const insertDroppedFilePaths = (files: File[]) => { + const insertDroppedFilePaths = (files: File[], hadImages: boolean): boolean => { + const reportFailure = (description: string) => { + toastManager.add({ type: "error", title: "Unable to add to chat", description }); + }; const resolvePath = window.desktopBridge?.getPathForFile; if (!resolvePath) { - setThreadError( - activeThreadId, + reportFailure( "Attaching files by path needs the desktop app. Paste an image, or type the path.", ); - return; + return false; } const paths = files .map((file) => resolvePath(file)) .filter((path): path is string => path !== null); const text = formatDroppedFilePaths(paths); if (text.length === 0) { - setThreadError(activeThreadId, "Could not read the location of the dropped file(s)."); - return; + reportFailure("Could not read the location of the dropped file(s)."); + return false; } if (!insertComposerTextAtEnd(text, { ensureLeadingBoundary: true })) { - toastManager.add({ - type: "error", - title: "Unable to add to chat", - description: "The composer is busy; try again once it is ready.", - }); + // This refusal comes from the same composer state that already rejected + // the images, so one drop never says it twice. + if (!hadImages) { + reportFailure("The composer is busy; try again once it is ready."); + } + return false; } + return true; }; const insertComposerTextAtEnd = ( @@ -3316,17 +3323,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (attachable.length > 0) { void addComposerAttachments(attachable); } - if (byPath.length > 0) { - // Deliberately no focusComposer() on this path. `applyPromptReplacement` - // focuses on the next frame, once Lexical has reconciled; focusing - // synchronously here makes the not-yet-reconciled editor sync its stale - // empty state back over the text we just inserted, so the drop looks - // like it silently did nothing. Same footgun the file-tree mention drop - // documents in makeComposerMentionDragHandlers. - insertDroppedFilePaths(byPath); - return; + const insertedPath = + byPath.length > 0 && insertDroppedFilePaths(byPath, attachable.length > 0); + // Focus unless a path just landed. `applyPromptReplacement` focuses on the + // next frame, once Lexical has reconciled; focusing synchronously right + // after the insert makes the not-yet-reconciled editor sync its stale empty + // state back over the text, so the drop looks like it silently did nothing + // — the footgun makeComposerMentionDragHandlers documents for the mention + // drop. Nothing inserted means nothing to lose, so focus as before. + if (!insertedPath) { + focusComposer(); } - focusComposer(); }, insertTextAtEnd: insertComposerTextAtEnd, openModelPicker: () => { From 0308c5ca1297ea34753d463467ab4ec415cbca53 Mon Sep 17 00:00:00 2001 From: Asaf Benatia Date: Fri, 21 Aug 2026 10:54:58 +0300 Subject: [PATCH 4/5] fix(composer): only stay silent when the images really said it first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Suppressing the busy toast for any drop that contained an image assumed the image path had already reported the same refusal. It only does for pending plan questions — the one guard addComposerImages shares with insertComposerTextAtEnd. While connecting, awaiting approval, or with no project selected, a mixed drop would attach the images and drop the paths without a word, which is the silent failure this branch exists to avoid. The suppression now requires that shared condition to actually hold. --- apps/web/src/components/chat/ChatComposer.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index a097238e6924..d650d10b3328 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -3177,9 +3177,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return false; } if (!insertComposerTextAtEnd(text, { ensureLeadingBoundary: true })) { - // This refusal comes from the same composer state that already rejected - // the images, so one drop never says it twice. - if (!hadImages) { + // Pending plan questions is the ONLY refusal `addComposerImages` shares + // with the insert, so that is the only case a mixed drop has already been + // told about. Staying silent for the others — connecting, approval, + // project selection — would attach the images and drop the paths without + // a word, which is the failure this whole branch exists to avoid. + const alreadyReported = hadImages && pendingUserInputs.length > 0; + if (!alreadyReported) { reportFailure("The composer is busy; try again once it is ready."); } return false; From f52c963546fa1e562247a02441a1ba37bbefba41 Mon Sep 17 00:00:00 2001 From: Asaf Benatia Date: Fri, 28 Aug 2026 17:36:47 +0300 Subject: [PATCH 5/5] fix(composer): only insert a dropped path where the agent can open it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drop resolved a path through the local desktop bridge and inserted it regardless of where the thread actually runs. On a remote environment — a LAN or Tailscale host, a relay, a tunnel, another desktop acting as server — that path names a file on the wrong machine, so the agent either cannot find it or finds a different one. Gate the insert on the thread's environment being the one this desktop app supervises (`desktop-managed`, matching environment ids) rather than on the bridge merely existing, and say so plainly when it is not. contracts/ipc.ts already states the rule this broke: resolve by environmentId rather than reaching through the local desktop bridge. The predicate is pure and covered: matching ids, mismatched ids, each non-desktop source, and either id still unknown (which refuses — bootstrapping is not evidence the thread runs here). --- apps/web/src/components/chat/ChatComposer.tsx | 21 +++++- .../components/chat/droppedFilePaths.test.ts | 75 ++++++++++++++++++- .../src/components/chat/droppedFilePaths.ts | 37 +++++++++ 3 files changed, 131 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index d650d10b3328..e9a171b2c88a 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -169,7 +169,9 @@ import { submitComposerDraft, } from "./composerSubmission"; import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; -import { formatDroppedFilePaths } from "./droppedFilePaths"; +import { getPrimaryKnownEnvironment } from "~/environments/primary"; + +import { droppedPathsResolveInEnvironment, formatDroppedFilePaths } from "./droppedFilePaths"; function ComposerVideoThumbnail({ file }: { file: File }) { const setVideo = useCallback( @@ -3168,6 +3170,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); return false; } + // The bridge reads a path on THIS machine. Inserting it into a thread whose + // agent runs elsewhere hands over a path that host cannot open — so gate on + // the thread's environment being the one this desktop app supervises, + // rather than on the bridge merely existing. + const primaryEnvironment = getPrimaryKnownEnvironment(); + if ( + !droppedPathsResolveInEnvironment({ + primarySource: primaryEnvironment?.source, + primaryEnvironmentId: primaryEnvironment?.environmentId, + threadEnvironmentId: environmentId, + }) + ) { + reportFailure( + "This thread runs on another machine, which cannot open a path from this one. Attach the file, or type a path that exists there.", + ); + return false; + } const paths = files .map((file) => resolvePath(file)) .filter((path): path is string => path !== null); diff --git a/apps/web/src/components/chat/droppedFilePaths.test.ts b/apps/web/src/components/chat/droppedFilePaths.test.ts index b07c9b23fb6d..ecc7a84b8fdd 100644 --- a/apps/web/src/components/chat/droppedFilePaths.test.ts +++ b/apps/web/src/components/chat/droppedFilePaths.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { formatDroppedFilePaths, quoteDroppedFilePath } from "./droppedFilePaths"; +import { + droppedPathsResolveInEnvironment, + formatDroppedFilePaths, + quoteDroppedFilePath, +} from "./droppedFilePaths"; describe("quoteDroppedFilePath", () => { it("leaves a path without whitespace alone", () => { @@ -39,3 +43,72 @@ describe("formatDroppedFilePaths", () => { expect(formatDroppedFilePaths([])).toBe(""); }); }); + +describe("droppedPathsResolveInEnvironment", () => { + const LOCAL = "env-local"; + const OTHER = "env-other"; + + it("accepts a thread running on the desktop-managed environment", () => { + expect( + droppedPathsResolveInEnvironment({ + primarySource: "desktop-managed", + primaryEnvironmentId: LOCAL, + threadEnvironmentId: LOCAL, + }), + ).toBe(true); + }); + + it("refuses a thread on a different environment even when the bridge is present", () => { + // The desktop app supervises env-local, but this thread runs on another + // machine: its agent cannot open a path read from this filesystem. + expect( + droppedPathsResolveInEnvironment({ + primarySource: "desktop-managed", + primaryEnvironmentId: LOCAL, + threadEnvironmentId: OTHER, + }), + ).toBe(false); + }); + + it.each(["configured", "manual", "window-origin"])( + "refuses a %s connection, which always names a server elsewhere", + (source) => { + expect( + droppedPathsResolveInEnvironment({ + primarySource: source, + primaryEnvironmentId: LOCAL, + threadEnvironmentId: LOCAL, + }), + ).toBe(false); + }, + ); + + it("refuses while either id is still unknown", () => { + // Bootstrapping is not evidence the thread runs here; guessing wrong + // inserts a path the agent silently cannot read. + expect( + droppedPathsResolveInEnvironment({ + primarySource: "desktop-managed", + primaryEnvironmentId: undefined, + threadEnvironmentId: LOCAL, + }), + ).toBe(false); + expect( + droppedPathsResolveInEnvironment({ + primarySource: "desktop-managed", + primaryEnvironmentId: LOCAL, + threadEnvironmentId: undefined, + }), + ).toBe(false); + }); + + it("refuses when there is no primary environment at all", () => { + expect( + droppedPathsResolveInEnvironment({ + primarySource: undefined, + primaryEnvironmentId: undefined, + threadEnvironmentId: LOCAL, + }), + ).toBe(false); + }); +}); diff --git a/apps/web/src/components/chat/droppedFilePaths.ts b/apps/web/src/components/chat/droppedFilePaths.ts index c8a70fa6ed7d..054eff4b3753 100644 --- a/apps/web/src/components/chat/droppedFilePaths.ts +++ b/apps/web/src/components/chat/droppedFilePaths.ts @@ -6,7 +6,44 @@ * * Paths are only available in the desktop app (Electron's `webUtils`); in a * browser tab the File object carries no filesystem path at all. + * + * A path is also only *meaningful* where the agent can open it. The desktop + * bridge resolves a path on the machine the client runs on, so the path is + * valid only when the thread's environment is that same machine — see + * `droppedPathsResolveInEnvironment`. + */ + +/** + * Whether a filesystem path read from the local desktop bridge means anything + * to the environment a thread runs in. + * + * The bridge resolves the path on *this* machine. `desktop-managed` is the one + * environment the desktop app supervises here, so only a thread bound to that + * environment shares the filesystem the path names. Every other connection — + * a LAN or Tailscale host, a relay, a tunnel, another desktop acting as server + * — runs the agent on a different machine, where the path either does not + * exist or, worse, names a different file. + * + * Passing the ids explicitly keeps this pure: the caller reads the primary + * environment, this decides. */ +export function droppedPathsResolveInEnvironment(input: { + readonly primarySource: string | undefined; + readonly primaryEnvironmentId: string | undefined; + readonly threadEnvironmentId: string | undefined; +}): boolean { + if (input.primarySource !== "desktop-managed") { + return false; + } + // Both ids must be known. A missing id is not a match: bootstrapping is not + // evidence that the thread runs here, and guessing wrong inserts a path the + // agent silently cannot read. + return ( + input.primaryEnvironmentId !== undefined && + input.threadEnvironmentId !== undefined && + input.primaryEnvironmentId === input.threadEnvironmentId + ); +} /** * Quote a path for the prompt when whitespace would make where it ends