Skip to content
This repository was archived by the owner on Feb 19, 2026. It is now read-only.

Commit fde0b39

Browse files
authored
fix: properly encode file URLs with special characters (anomalyco#12424)
1 parent e9a3cfc commit fde0b39

File tree

12 files changed

+114
-22
lines changed

12 files changed

+114
-22
lines changed

packages/app/src/components/file-tree.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ import {
1919
import { Dynamic } from "solid-js/web"
2020
import type { FileNode } from "@opencode-ai/sdk/v2"
2121

22+
function pathToFileUrl(filepath: string): string {
23+
const encodedPath = filepath
24+
.split("/")
25+
.map((segment) => encodeURIComponent(segment))
26+
.join("/")
27+
return `file://${encodedPath}`
28+
}
29+
2230
type Kind = "add" | "del" | "mix"
2331

2432
type Filter = {
@@ -247,7 +255,7 @@ export default function FileTree(props: {
247255
onDragStart={(e: DragEvent) => {
248256
if (!draggable()) return
249257
e.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
250-
e.dataTransfer?.setData("text/uri-list", `file://${local.node.path}`)
258+
e.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
251259
if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy"
252260

253261
const dragImage = document.createElement("div")

packages/app/src/components/prompt-input/build-request-parts.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ type BuildRequestPartsInput = {
3030
const absolute = (directory: string, path: string) =>
3131
path.startsWith("/") ? path : (directory + "/" + path).replace("//", "/")
3232

33+
const encodeFilePath = (filepath: string): string =>
34+
filepath
35+
.split("/")
36+
.map((segment) => encodeURIComponent(segment))
37+
.join("/")
38+
3339
const fileQuery = (selection: FileSelection | undefined) =>
3440
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
3541

@@ -99,7 +105,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
99105
id: Identifier.ascending("part"),
100106
type: "file",
101107
mime: "text/plain",
102-
url: `file://${path}${fileQuery(attachment.selection)}`,
108+
url: `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
103109
filename: getFilename(attachment.path),
104110
source: {
105111
type: "file",
@@ -129,7 +135,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
129135
const used = new Set(files.map((part) => part.url))
130136
const context = input.context.flatMap((item) => {
131137
const path = absolute(input.sessionDirectory, item.path)
132-
const url = `file://${path}${fileQuery(item.selection)}`
138+
const url = `file://${encodeFilePath(path)}${fileQuery(item.selection)}`
133139
const comment = item.comment?.trim()
134140
if (!comment && used.has(url)) return []
135141
used.add(url)

packages/app/src/context/file/path.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,27 @@ export function unquoteGitPath(input: string) {
7272
return new TextDecoder().decode(new Uint8Array(bytes))
7373
}
7474

75+
export function decodeFilePath(input: string) {
76+
try {
77+
return decodeURIComponent(input)
78+
} catch {
79+
return input
80+
}
81+
}
82+
83+
export function encodeFilePath(filepath: string): string {
84+
return filepath
85+
.split("/")
86+
.map((segment) => encodeURIComponent(segment))
87+
.join("/")
88+
}
89+
7590
export function createPathHelpers(scope: () => string) {
7691
const normalize = (input: string) => {
7792
const root = scope()
7893
const prefix = root.endsWith("/") ? root : root + "/"
7994

80-
let path = unquoteGitPath(stripQueryAndHash(stripFileProtocol(input)))
95+
let path = unquoteGitPath(decodeFilePath(stripQueryAndHash(stripFileProtocol(input))))
8196

8297
if (path.startsWith(prefix)) {
8398
path = path.slice(prefix.length)
@@ -100,7 +115,7 @@ export function createPathHelpers(scope: () => string) {
100115

101116
const tab = (input: string) => {
102117
const path = normalize(input)
103-
return `file://${path}`
118+
return `file://${encodeFilePath(path)}`
104119
}
105120

106121
const pathFromTab = (tabValue: string) => {

packages/opencode/src/acp/agent.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
} from "@agentclientprotocol/sdk"
3030

3131
import { Log } from "../util/log"
32+
import { pathToFileURL } from "bun"
3233
import { ACPSessionManager } from "./session"
3334
import type { ACPConfig } from "./types"
3435
import { Provider } from "../provider/provider"
@@ -986,7 +987,7 @@ export namespace ACP {
986987
type: "image",
987988
mimeType: effectiveMime,
988989
data: base64Data,
989-
uri: `file://${filename}`,
990+
uri: pathToFileURL(filename).href,
990991
},
991992
},
992993
})
@@ -996,13 +997,14 @@ export namespace ACP {
996997
} else {
997998
// Non-image: text types get decoded, binary types stay as blob
998999
const isText = effectiveMime.startsWith("text/") || effectiveMime === "application/json"
1000+
const fileUri = pathToFileURL(filename).href
9991001
const resource = isText
10001002
? {
1001-
uri: `file://${filename}`,
1003+
uri: fileUri,
10021004
mimeType: effectiveMime,
10031005
text: Buffer.from(base64Data, "base64").toString("utf-8"),
10041006
}
1005-
: { uri: `file://${filename}`, mimeType: effectiveMime, blob: base64Data }
1007+
: { uri: fileUri, mimeType: effectiveMime, blob: base64Data }
10061008

10071009
await this.connection
10081010
.sessionUpdate({
@@ -1544,7 +1546,7 @@ export namespace ACP {
15441546
const name = path.split("/").pop() || path
15451547
return {
15461548
type: "file",
1547-
url: `file://${path}`,
1549+
url: pathToFileURL(path).href,
15481550
filename: name,
15491551
mime: "text/plain",
15501552
}

packages/opencode/src/cli/cmd/run.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Argv } from "yargs"
22
import path from "path"
3+
import { pathToFileURL } from "bun"
34
import { UI } from "../ui"
45
import { cmd } from "./cmd"
56
import { Flag } from "../../flag/flag"
@@ -314,7 +315,7 @@ export const RunCommand = cmd({
314315

315316
files.push({
316317
type: "file",
317-
url: `file://${resolvedPath}`,
318+
url: pathToFileURL(resolvedPath).href,
318319
filename: path.basename(resolvedPath),
319320
mime,
320321
})

packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { TextAttributes } from "@opentui/core"
2+
import { fileURLToPath } from "bun"
23
import { useTheme } from "../context/theme"
34
import { useDialog } from "@tui/ui/dialog"
45
import { useSync } from "@tui/context/sync"
@@ -19,7 +20,7 @@ export function DialogStatus() {
1920
const list = sync.data.config.plugin ?? []
2021
const result = list.map((value) => {
2122
if (value.startsWith("file://")) {
22-
const path = value.substring("file://".length)
23+
const path = fileURLToPath(value)
2324
const parts = path.split("/")
2425
const filename = parts.pop() || path
2526
if (!filename.includes(".")) return { name: filename }

packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { BoxRenderable, TextareaRenderable, KeyEvent, ScrollBoxRenderable } from "@opentui/core"
2+
import { pathToFileURL } from "bun"
23
import fuzzysort from "fuzzysort"
34
import { firstBy } from "remeda"
45
import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js"
@@ -246,17 +247,17 @@ export function Autocomplete(props: {
246247
const width = props.anchor().width - 4
247248
options.push(
248249
...sortedFiles.map((item): AutocompleteOption => {
249-
let url = `file://${process.cwd()}/${item}`
250+
const fullPath = `${process.cwd()}/${item}`
251+
const urlObj = pathToFileURL(fullPath)
250252
let filename = item
251253
if (lineRange && !item.endsWith("/")) {
252254
filename = `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}`
253-
const urlObj = new URL(url)
254255
urlObj.searchParams.set("start", String(lineRange.startLine))
255256
if (lineRange.endLine !== undefined) {
256257
urlObj.searchParams.set("end", String(lineRange.endLine))
257258
}
258-
url = urlObj.toString()
259259
}
260+
const url = urlObj.href
260261

261262
const isDir = item.endsWith("/")
262263
return {

packages/opencode/src/lsp/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Bus } from "@/bus"
33
import { Log } from "../util/log"
44
import { LSPClient } from "./client"
55
import path from "path"
6-
import { pathToFileURL } from "url"
6+
import { pathToFileURL, fileURLToPath } from "url"
77
import { LSPServer } from "./server"
88
import z from "zod"
99
import { Config } from "../config/config"
@@ -369,7 +369,7 @@ export namespace LSP {
369369
}
370370

371371
export async function documentSymbol(uri: string) {
372-
const file = new URL(uri).pathname
372+
const file = fileURLToPath(uri)
373373
return run(file, (client) =>
374374
client.connection
375375
.sendRequest("textDocument/documentSymbol", {

packages/opencode/src/session/prompt.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import { Flag } from "../flag/flag"
3232
import { ulid } from "ulid"
3333
import { spawn } from "child_process"
3434
import { Command } from "../command"
35-
import { $, fileURLToPath } from "bun"
35+
import { $, fileURLToPath, pathToFileURL } from "bun"
3636
import { ConfigMarkdown } from "../config/markdown"
3737
import { SessionSummary } from "./summary"
3838
import { NamedError } from "@opencode-ai/util/error"
@@ -210,7 +210,7 @@ export namespace SessionPrompt {
210210
if (stats.isDirectory()) {
211211
parts.push({
212212
type: "file",
213-
url: `file://${filepath}`,
213+
url: pathToFileURL(filepath).href,
214214
filename: name,
215215
mime: "application/x-directory",
216216
})
@@ -219,7 +219,7 @@ export namespace SessionPrompt {
219219

220220
parts.push({
221221
type: "file",
222-
url: `file://${filepath}`,
222+
url: pathToFileURL(filepath).href,
223223
filename: name,
224224
mime: "text/plain",
225225
})
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import path from "path"
2+
import { describe, expect, test } from "bun:test"
3+
import { fileURLToPath } from "url"
4+
import { Instance } from "../../src/project/instance"
5+
import { Log } from "../../src/util/log"
6+
import { Session } from "../../src/session"
7+
import { SessionPrompt } from "../../src/session/prompt"
8+
import { MessageV2 } from "../../src/session/message-v2"
9+
import { tmpdir } from "../fixture/fixture"
10+
11+
Log.init({ print: false })
12+
13+
describe("session.prompt special characters", () => {
14+
test("handles filenames with # character", async () => {
15+
await using tmp = await tmpdir({
16+
git: true,
17+
init: async (dir) => {
18+
await Bun.write(path.join(dir, "file#name.txt"), "special content\n")
19+
},
20+
})
21+
22+
await Instance.provide({
23+
directory: tmp.path,
24+
fn: async () => {
25+
const session = await Session.create({})
26+
const template = "Read @file#name.txt"
27+
const parts = await SessionPrompt.resolvePromptParts(template)
28+
const fileParts = parts.filter((part) => part.type === "file")
29+
30+
expect(fileParts.length).toBe(1)
31+
expect(fileParts[0].filename).toBe("file#name.txt")
32+
33+
// Verify the URL is properly encoded (# should be %23)
34+
expect(fileParts[0].url).toContain("%23")
35+
36+
// Verify the URL can be correctly converted back to a file path
37+
const decodedPath = fileURLToPath(fileParts[0].url)
38+
expect(decodedPath).toBe(path.join(tmp.path, "file#name.txt"))
39+
40+
const message = await SessionPrompt.prompt({
41+
sessionID: session.id,
42+
parts,
43+
noReply: true,
44+
})
45+
const stored = await MessageV2.get({ sessionID: session.id, messageID: message.info.id })
46+
47+
// Verify the file content was read correctly
48+
const textParts = stored.parts.filter((part) => part.type === "text")
49+
const hasContent = textParts.some((part) => part.text.includes("special content"))
50+
expect(hasContent).toBe(true)
51+
52+
await Session.remove(session.id)
53+
},
54+
})
55+
})
56+
})

0 commit comments

Comments
 (0)