fix: handle non-ISO-8859-1 characters in fetch headers and Content-Disposition (#1673)

* fix: handle non-ISO-8859-1 characters in fetch headers and Content-Disposition

Browser Headers API rejects characters above U+00FF. The x-opencode-directory header carries raw filesystem paths, which breaks when paths contain Chinese/CJK characters. Also fixes Content-Disposition for non-ASCII filenames per RFC 5987.

* refactor: export header sanitization helpers, deduplicate, add tests

Export isLatin1Safe and sanitizeHeadersForBrowser from runtime-fetch.ts so VS Code webview can import them instead of duplicating the logic. Add tests: isLatin1Safe boundary checks, sanitizeHeadersForBrowser encoding/deduplication, runtimeFetch round-trip encode/decode, and Content-Disposition RFC 5987 output for both ASCII and non-ASCII filenames.

* fix: mark encoded directory headers

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
FanFan4204
2026-06-23 19:49:44 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 43f677d56d
commit efd621b087
7 changed files with 289 additions and 8 deletions
+47 -2
View File
@@ -96,10 +96,55 @@ const shouldAttachRuntimeAuth = (input: string | URL | Request): boolean => {
}
};
// Headers API only accepts ISO-8859-1 (Latin-1) characters. Any value containing
// characters outside \u0000-\u00FF causes "Failed to construct/set 'Headers':
// String contains non ISO-8859-1 code point." Encode those values so they round-trip
// safely through the browser's Headers API. Directory hints are always encoded
// with an explicit marker header so the server decodes only values produced by
// this transport and preserves literal percent sequences from direct clients.
export const isLatin1Safe = (value: string): boolean => {
for (let i = 0; i < value.length; i += 1) {
if (value.charCodeAt(i) > 0xFF) return false;
}
return true;
};
const shouldEncodeHeaderValue = (key: string, value: string): boolean => (
key.toLowerCase() === 'x-opencode-directory' || !isLatin1Safe(value)
);
export const sanitizeHeadersForBrowser = (init?: HeadersInit): [string, string][] | undefined => {
if (!init) return undefined;
// Normalize any HeadersInit shape into a plain array of entries so we can
// safely inspect and re-encode non-Latin-1 values.
const sourceEntries: [string, string][] = init instanceof Headers
? Array.from(init.entries())
: Array.isArray(init)
? init
: Object.entries(init);
if (sourceEntries.length === 0) return undefined;
const entries: [string, string][] = [];
let dirty = false;
let encodedDirectoryHint = false;
for (const [key, value] of sourceEntries) {
if (shouldEncodeHeaderValue(key, value)) {
entries.push([key, encodeURIComponent(value)]);
dirty = true;
if (key.toLowerCase() === 'x-opencode-directory') encodedDirectoryHint = true;
} else {
entries.push([key, value]);
}
}
if (encodedDirectoryHint) {
entries.push(['x-opencode-directory-encoding', 'uri']);
}
return dirty ? entries : undefined;
};
const mergeHeaders = async (inputHeaders?: HeadersInit, initHeaders?: HeadersInit, attachAuth = true): Promise<Headers> => {
const headers = new Headers(inputHeaders);
const headers = new Headers(sanitizeHeadersForBrowser(inputHeaders) ?? inputHeaders);
if (initHeaders) {
new Headers(initHeaders).forEach((value, key) => headers.set(key, value));
new Headers(sanitizeHeadersForBrowser(initHeaders) ?? initHeaders).forEach((value, key) => headers.set(key, value));
}
if (!attachAuth) {
return headers;