diff --git a/apps/desktop/src/components/settings/ProviderHeadersEditor.tsx b/apps/desktop/src/components/settings/ProviderHeadersEditor.tsx index 20a2061828..7be4d9209d 100644 --- a/apps/desktop/src/components/settings/ProviderHeadersEditor.tsx +++ b/apps/desktop/src/components/settings/ProviderHeadersEditor.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState, type ChangeEvent } from "react"; import { useTranslation } from "react-i18next"; -import { APP_VERSION } from "@pi-desktop/shared"; +import { APP_VERSION, inspectHeaderValue } from "@pi-desktop/shared"; import { KeyValueRows, pairsToRecord, @@ -68,6 +68,22 @@ export function ProviderHeadersEditor({ useEffect(() => () => window.clearTimeout(copyTimer.current), []); + // A value the host folds on save, and one it will refuse, are both said next + // to the rows — a fullwidth character is an IME slip, not a mystery. Only + // rows that will actually be persisted count: an unnamed, empty or already + // refused row has nothing to fold, and saying otherwise would read as if the + // whole row were fine. + const headerRows = pairs.map((pair) => { + const header = inspectHeaderValue(pair.value); + const storable = + pair.key.trim() !== "" && header.value !== "" && header.fault === null; + return { header, storable }; + }); + const foldedHeaderValue = headerRows.some( + (row) => row.storable && row.header.folded, + ); + const faultyHeaderValue = headerRows.some((row) => row.header.fault !== null); + const addPreset = (key: string) => { const preset = HEADER_PRESETS.find((item) => item.key === key); if (!preset) return; @@ -157,6 +173,16 @@ export function ProviderHeadersEditor({ {t("settings.headersImportError")} ) : null} + {foldedHeaderValue ? ( +
+ {t("settings.headersFullwidthFolded")} +
+ ) : null} + {faultyHeaderValue ? ( +
+ {t("settings.headersValueNotLatin1")} +
+ ) : null}
{ assert.doesNotMatch(styles, /\.vendor-account-chosen/); assert.doesNotMatch(styles, /\.vendor-account-custom-model/); }); + +test("Advanced says a fullwidth value folds and a non-Latin-1 value is refused", () => { + // The rule itself lives in @pi-desktop/shared (unit-tested there) and is + // mirrored in host-core; this pins that the editor asks it and renders both + assert.match(headerEditorSource, /import \{ APP_VERSION, inspectHeaderValue \}/); + assert.match(headerEditorSource, /inspectHeaderValue\(pair\.value\)/); + // Only a row that will be persisted may claim it folds: the hint has to + // agree with what the host and the runtime do with the row. + assert.match(headerEditorSource, /pair\.key\.trim\(\) !== ""/); + assert.match(headerEditorSource, /header\.value !== ""/); + assert.match(headerEditorSource, /header\.fault === null/); + assert.match(headerEditorSource, /row\.storable && row\.header\.folded/); + assert.match(headerEditorSource, /provider-setup-header-note/); + assert.match(headerEditorSource, /role="status"/); + assert.match(headerEditorSource, /role="alert"/); + assert.match(headerEditorSource, /settings\.headersFullwidthFolded/); + assert.match(headerEditorSource, /settings\.headersValueNotLatin1/); + assert.match(block(".provider-setup-header-note"), /color: var\(--ds-text-muted\)/); +}); diff --git a/crates/host-core/src/config_sync/apply.rs b/crates/host-core/src/config_sync/apply.rs index 0638402973..0585b6d280 100644 --- a/crates/host-core/src/config_sync/apply.rs +++ b/crates/host-core/src/config_sync/apply.rs @@ -150,6 +150,12 @@ fn apply_entity( object.insert("secretValue".into(), Value::String(secret.clone())); } } + // A bundle from a peer on an older build, or a backup taken before the + // header rule was tightened, can carry a value this build refuses. That + // is dropped here — the rule `config_headers` already applies when + // reading a store — so one stale row cannot fail the whole revision, + // which is what the strict write the editor goes through would do. + providers::retain_storable_headers(&mut payload); let exists = st .db .conn() diff --git a/crates/host-core/src/providers.rs b/crates/host-core/src/providers.rs index 87c0f15ff8..14c0bdbaa9 100644 --- a/crates/host-core/src/providers.rs +++ b/crates/host-core/src/providers.rs @@ -42,13 +42,13 @@ pub(crate) use credentials::{ config_reasoning_override, config_value, config_with_headers, config_with_limit, config_with_oauth_account_label, config_with_reasoning_override, config_with_thinking_levels_override, ensure_config_object, limit_temperature_value, - limit_u32_value, limits_object, merge_provider_config_overrides, upsert_secret_meta, - LimitOverrides, + limit_u32_value, limits_object, merge_provider_config_overrides, retain_storable_headers, + upsert_secret_meta, LimitOverrides, }; pub(crate) use validation::{ - config_limit_f64, config_limit_u32, normalize_headers_input, normalize_one_header, - normalize_thinking_levels, valid_header_key, validate_model_aliases, MAX_HEADERS, - MAX_MODEL_ALIAS_CHARS, + config_limit_f64, config_limit_u32, fold_fullwidth, header_value_fault, + normalize_headers_input, normalize_one_header, normalize_thinking_levels, storable_headers, + valid_header_key, validate_model_aliases, MAX_HEADERS, MAX_MODEL_ALIAS_CHARS, }; #[cfg(test)] diff --git a/crates/host-core/src/providers/credentials.rs b/crates/host-core/src/providers/credentials.rs index 83b07be994..9bff92deef 100644 --- a/crates/host-core/src/providers/credentials.rs +++ b/crates/host-core/src/providers/credentials.rs @@ -32,20 +32,10 @@ pub(crate) fn config_headers(raw: &str) -> Option> { collected.insert("User-Agent".into(), user_agent.to_string()); } } - let mut by_lower: BTreeMap = BTreeMap::new(); - for (key, value) in collected { - if let Ok(Some((normalized_key, normalized_value))) = normalize_one_header(&key, &value) { - by_lower.insert( - normalized_key.to_ascii_lowercase(), - (normalized_key, normalized_value), - ); - } - } - let out: BTreeMap<_, _> = by_lower - .into_iter() - .take(MAX_HEADERS) - .map(|(_, pair)| pair) - .collect(); + // A stored map is read, not written: rows this build refuses (a value with + // a character no header can carry, a reserved name) are dropped rather than + // reported, so an older store cannot fail a turn. + let out = storable_headers(&collected); if out.is_empty() { None } else { @@ -53,6 +43,36 @@ pub(crate) fn config_headers(raw: &str) -> Option> { } } +/// Fold and drop the `headers` object of a provider payload before it is +/// deserialized into a write input. A bundle from a peer on an older build, or +/// a backup taken before the header rule was tightened, can carry a value this +/// build refuses; failing the whole sync revision over one stale row is worse +/// than dropping it, and the row is already invisible on read (`config_headers` +/// drops the same cases). +pub(crate) fn retain_storable_headers(payload: &mut serde_json::Value) { + let Some(object) = payload.as_object_mut() else { + return; + }; + let Some(headers) = object.get("headers").and_then(serde_json::Value::as_object) else { + return; + }; + let raw: BTreeMap = headers + .iter() + .filter_map(|(key, value)| Some((key.clone(), value.as_str()?.to_string()))) + .collect(); + if raw.is_empty() { + return; + } + let storable = storable_headers(&raw); + if storable.is_empty() { + object.remove("headers"); + } else { + object.insert( + "headers".into(), + serde_json::to_value(storable).unwrap_or_default(), + ); + } +} /// Set or clear optional headers. An empty map clears them and drops leftover `userAgent`. pub(crate) fn config_with_headers(raw: &str, headers: &BTreeMap) -> Result { let mut config = ensure_config_object(raw)?; @@ -292,6 +312,11 @@ pub(crate) fn upsert_secret_meta( Ok(()) } +/// Read a provider's API key, folding fullwidth IME input the same way header +/// values are folded. The key is signed into `Authorization` (or `x-api-key`) +/// headers, where a fullwidth character can never be valid, so a key stored +/// before this rule existed still authenticates after an upgrade. Applied on +/// read as well as on write because only this accessor feeds outbound requests. pub fn get_secret_for_provider( db: &Database, secrets: &SecretStore, @@ -304,7 +329,7 @@ pub fn get_secret_for_provider( .optional()? .flatten(); if let Some(sref) = secret_ref { - secrets.get(&sref) + Ok(secrets.get(&sref)?.map(|value| fold_fullwidth(&value))) } else { Ok(None) } diff --git a/crates/host-core/src/providers/repository.rs b/crates/host-core/src/providers/repository.rs index b994106783..0b180c1636 100644 --- a/crates/host-core/src/providers/repository.rs +++ b/crates/host-core/src/providers/repository.rs @@ -424,7 +424,9 @@ pub(crate) fn delete_provider_row(db: &Database, secrets: &SecretStore, id: &str /// `api_key` reference and the row's `secret_ref` change; no field the plugin's /// manifest owns is touched, so the next load still refreshes the declaration. /// -/// An empty value deletes the stored key and clears `secret_ref`. +/// An empty value deletes the stored key and clears `secret_ref`. A fullwidth +/// value is folded to half-width, the same rule header values follow, because +/// the key is signed into an HTTP header. pub fn set_provider_secret( db: &Database, secrets: &SecretStore, @@ -435,12 +437,10 @@ pub fn set_provider_secret( return Ok(None); } let api_key_ref = secret_ref_for_provider(id); - match secret_value - .map(str::trim) - .filter(|value| !value.is_empty()) - { + let secret_value = secret_value.map(str::trim).map(fold_fullwidth); + match secret_value.filter(|value| !value.is_empty()) { Some(value) => { - let backend = secrets.set(&api_key_ref, value)?; + let backend = secrets.set(&api_key_ref, &value)?; upsert_secret_meta(db, &api_key_ref, id, &backend)?; db.conn() .prepare_cached( diff --git a/crates/host-core/src/providers/tests.rs b/crates/host-core/src/providers/tests.rs index 38b4ff4794..8d05ea680b 100644 --- a/crates/host-core/src/providers/tests.rs +++ b/crates/host-core/src/providers/tests.rs @@ -1364,3 +1364,244 @@ fn degraded_model_array_cannot_be_overwritten_by_update() { Some("old-secret") ); } + +#[test] +fn header_values_fold_fullwidth_and_reject_non_latin1() { + let (_dir, db, secrets) = test_context(); + let headers = BTreeMap::from([ + ("X-Title".to_string(), "PI\u{3000}Desktop".to_string()), + ("X-Key".to_string(), "1234567\u{FF10}".to_string()), + ]); + let provider = create_provider( + &db, + &secrets, + ProviderCreateInput { + name: "Custom".into(), + vendor_key: None, + provider_type: None, + protocol: None, + base_url: None, + auth_kind: Some("none".into()), + models: None, + default_model_id: Some("model-1".into()), + secret_value: None, + api_style: None, + oauth_account_label: None, + headers: Some(headers), + context_window: None, + max_output_tokens: None, + temperature: None, + supports_reasoning: None, + supported_thinking_levels: None, + }, + ) + .unwrap(); + let stored = provider.headers.unwrap(); + assert_eq!(stored["X-Title"], "PI Desktop"); + assert_eq!(stored["X-Key"], "12345670"); + + // A value with no ASCII counterpart is refused at the boundary and names + // the character undici would have thrown on. + let err = create_provider( + &db, + &secrets, + ProviderCreateInput { + name: "Star".into(), + vendor_key: None, + provider_type: None, + protocol: None, + base_url: None, + auth_kind: Some("none".into()), + models: None, + default_model_id: Some("model-1".into()), + secret_value: None, + api_style: None, + oauth_account_label: None, + headers: Some(BTreeMap::from([( + "X-Title".to_string(), + "abc\u{661F}".to_string(), + )])), + context_window: None, + max_output_tokens: None, + temperature: None, + supports_reasoning: None, + supported_thinking_levels: None, + }, + ) + .unwrap_err() + .to_string(); + assert!(err.contains("HEADERS_INVALID"), "{err}"); + assert!(err.contains("U+661F"), "{err}"); + assert!(err.contains("character index 3"), "{err}"); + + // A control character is the same class of failure — it never reaches a + // header either — so it is named too. + let err = normalize_one_header("X-Title", "ab\u{0}cd") + .unwrap_err() + .to_string(); + assert!(err.contains("U+0000 at character index 2"), "{err}"); + + // A character above U+00FF is the fault wherever it sits, and the reported + // index counts code units exactly as undici would: a surrogate pair can + // never sit before the first fault, because it is one. + let err = normalize_one_header("X-Title", "\u{1F44D}abc") + .unwrap_err() + .to_string(); + assert!(err.contains("character index 0"), "{err}"); + + // Trim matches what JavaScript trims, so the host accepts a value the + // editor showed as clean: a pasted byte-order mark is whitespace to both, + // and U+0085 is whitespace to neither (it travels as Latin-1). + assert_eq!( + normalize_one_header("X-Title", "\u{FEFF}pi-desktop\u{FEFF}").unwrap(), + Some(("X-Title".to_string(), "pi-desktop".to_string())) + ); + assert_eq!( + normalize_one_header("X-Title", "\u{85}abc").unwrap(), + Some(("X-Title".to_string(), "\u{85}abc".to_string())) + ); + + // A value that is only the ideographic space folds to nothing, and an + // unnamed row is still absent rather than a missing-name error. + assert_eq!(normalize_one_header("X-Title", "\u{3000}").unwrap(), None); + assert_eq!(normalize_one_header("", "\u{3000}").unwrap(), None); + assert_eq!( + normalize_one_header("X-Title", "\u{FF10}").unwrap(), + Some(("X-Title".to_string(), "0".to_string())) + ); + + // Folding shrinks bytes, so a fullwidth value that was over the bound + // (4096 bytes, `MAX_HEADER_VALUE_BYTES`) becomes storable — the one input + // class this change newly accepts. + let long_fullwidth = "\u{FF41}".repeat(4096); + assert!(normalize_one_header("X-Title", &long_fullwidth) + .unwrap() + .is_some()); + let long_ascii = "a".repeat(4097); + let err = normalize_one_header("X-Title", &long_ascii) + .unwrap_err() + .to_string(); + assert!(err.contains("header value is too long"), "{err}"); + + // A store written before the rule existed is sanitized on read: fullwidth + // folds, and the row that cannot travel is dropped rather than thrown. + db.conn() + .execute( + "UPDATE providers SET config_json = ?1 WHERE id = ?2", + params![ + json!({ "headers": { "x-legacy": "\u{FF11}\u{FF12}\u{FF13}", "x-cjk": "星" } }) + .to_string(), + provider.id + ], + ) + .unwrap(); + let read = get_provider(&db, &secrets, &provider.id).unwrap().unwrap(); + let read = read.headers.unwrap(); + assert_eq!(read["x-legacy"], "123"); + assert!(!read.contains_key("x-cjk")); +} + +#[test] +fn provider_api_keys_fold_fullwidth_on_write_and_read() { + let (_dir, db, secrets) = test_context(); + let provider = create_provider( + &db, + &secrets, + ProviderCreateInput { + name: "Custom".into(), + vendor_key: None, + provider_type: None, + protocol: None, + base_url: None, + auth_kind: Some("api_key".into()), + models: None, + default_model_id: Some("model-1".into()), + secret_value: Some("sk-\u{FF10}\u{FF11}".into()), + api_style: None, + oauth_account_label: None, + headers: None, + context_window: None, + max_output_tokens: None, + temperature: None, + supports_reasoning: None, + supported_thinking_levels: None, + }, + ) + .unwrap(); + + // create/update store what they were handed (config-sync and the renderer + // both rely on that), so the fold has to hold on the read accessor — which + // is the only path outbound requests take. + assert_eq!( + secrets + .get(&secret_ref_for_provider(&provider.id)) + .unwrap() + .as_deref(), + Some("sk-\u{FF10}\u{FF11}") + ); + assert_eq!( + get_secret_for_provider(&db, &secrets, &provider.id) + .unwrap() + .as_deref(), + Some("sk-01") + ); + + // The settings save path folds on write too. + set_provider_secret( + &db, + &secrets, + &provider.id, + Some("\u{FF53}\u{FF4B}-\u{FF11}"), + ) + .unwrap(); + assert_eq!( + get_secret_for_provider(&db, &secrets, &provider.id) + .unwrap() + .as_deref(), + Some("sk-1") + ); +} + +#[test] +fn sync_payloads_keep_only_storable_headers() { + // A peer on an older build, or a backup taken before the header rule was + // tightened, can hand us rows this build refuses. Dropping them at the sync + // boundary is what keeps one stale row from failing a whole revision, and + // it is the same set `config_headers` drops when a store is read. + let mut payload = json!({ + "name": "Custom", + "headers": { + "X-Title": "PI\u{3000}Desktop", + "X-Key": "1234567\u{FF10}", + "X-CJK": "星", + "Authorization": "Bearer secret" + } + }); + retain_storable_headers(&mut payload); + assert_eq!(payload["headers"]["X-Title"], "PI Desktop"); + assert_eq!(payload["headers"]["X-Key"], "12345670"); + assert!(payload["headers"].get("X-CJK").is_none()); + assert!(payload["headers"].get("Authorization").is_none()); + // The write path that aborted the revision can no longer refuse it. + let headers: BTreeMap = + serde_json::from_value(payload["headers"].clone()).unwrap(); + assert!(normalize_headers_input(&headers).is_ok()); + + // Every row unusable: the key goes away instead of storing an empty map. + let mut payload = json!({ "headers": { "X-CJK": "星" } }); + retain_storable_headers(&mut payload); + assert!(payload.get("headers").is_none()); + + // A payload without headers, and one that is not an object, are untouched. + let mut payload = json!({ "name": "Custom" }); + retain_storable_headers(&mut payload); + assert_eq!(payload, json!({ "name": "Custom" })); + let mut payload = json!("not an object"); + retain_storable_headers(&mut payload); + assert_eq!(payload, json!("not an object")); + + // A row dropped here is also invisible to the read path, so a local store + // holding it behaves the same before and after this runs. + let raw = BTreeMap::from([("x-cjk".to_string(), "星".to_string())]); + assert!(storable_headers(&raw).is_empty()); +} diff --git a/crates/host-core/src/providers/validation.rs b/crates/host-core/src/providers/validation.rs index 7ee621f9fa..13172497fa 100644 --- a/crates/host-core/src/providers/validation.rs +++ b/crates/host-core/src/providers/validation.rs @@ -32,9 +32,61 @@ pub(crate) fn valid_header_key(key: &str) -> bool { first.is_ascii_alphanumeric() && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') } +/// Fold the fullwidth block (U+FF01–U+FF5E) and the ideographic space (U+3000) +/// onto ASCII. This is what a Chinese/Japanese IME or a fullwidth-formatted +/// page produces for plain ASCII — `0` is U+FF10 — so folding it back is the +/// user's intent, not a rewrite of it. +/// +/// Deliberately not a full NFKC pass: NFKC would also turn halfwidth katakana +/// `ア` into U+30A2 and emit combining marks, replacing one unusable value with +/// another. Mirrors `foldFullwidthHeaderValue` in `@pi-desktop/shared`. +pub(crate) fn fold_fullwidth(value: &str) -> String { + value + .chars() + .map(|ch| { + let code = ch as u32; + if (0xFF01..=0xFF5E).contains(&code) { + char::from_u32(code - 0xFEE0).unwrap_or(ch) + } else if code == 0x3000 { + ' ' + } else { + ch + } + }) + .collect() +} + +/// First character an HTTP header value cannot carry, with the code-unit index +/// undici would name in `Cannot convert argument to a ByteString because the +/// character at index N ...`. HTTP header values are ByteStrings: HTAB, +/// printable ASCII, and the Latin-1 supplement travel; NUL, the other C0 +/// controls, DEL, and every code point above U+00FF do not. Mirrors +/// `HEADER_VALUE_ALLOWED` in `@pi-desktop/shared`. +/// +/// The first fault always sits below U+0100, and every character before it is +/// below U+0100 too, so a char index and a UTF-16 code-unit index agree here — +/// the two engines cannot report different positions. +pub(crate) fn header_value_fault(value: &str) -> Option<(usize, char)> { + value.chars().enumerate().find(|(_, ch)| { + let code = *ch as u32; + !(code == 0x09 || (0x20..=0x7E).contains(&code) || (0x80..=0xFF).contains(&code)) + }) +} + +/// Trim exactly what `String.prototype.trim` trims, because the renderer and +/// the runtime normalize values with it: `char::is_whitespace` is the Unicode +/// White_Space property, which includes U+0085 (NEL) that JavaScript keeps, and +/// excludes U+FEFF (a byte-order mark pasted from a file) that JavaScript +/// removes. Diverging here would let the host refuse a value the editor showed +/// as clean, or store a byte the runtime would have stripped. +fn is_header_trim(ch: char) -> bool { + ch != '\u{85}' && (ch.is_whitespace() || ch == '\u{FEFF}') +} + pub(crate) fn normalize_one_header(key: &str, value: &str) -> Result> { - let key = key.trim(); - let value = value.trim(); + let key = key.trim_matches(is_header_trim); + let value = fold_fullwidth(value.trim_matches(is_header_trim)); + let value = value.as_str(); if key.is_empty() { if value.is_empty() { return Ok(None); @@ -56,6 +108,17 @@ pub(crate) fn normalize_one_header(key: &str, value: &str) -> Result) -> BTreeMap { + let mut by_lower: BTreeMap = BTreeMap::new(); + for (key, value) in raw { + if let Ok(Some((normalized_key, normalized_value))) = normalize_one_header(key, value) { + by_lower.insert( + normalized_key.to_ascii_lowercase(), + (normalized_key, normalized_value), + ); + } + } + by_lower.into_values().take(MAX_HEADERS).collect() +} + pub(crate) fn normalize_thinking_levels(levels: &[String]) -> Vec { let mut out = Vec::new(); for level in levels { diff --git a/docs/adr/0178-per-provider-custom-headers.md b/docs/adr/0178-per-provider-custom-headers.md index b6602fef44..820e1d2b37 100644 --- a/docs/adr/0178-per-provider-custom-headers.md +++ b/docs/adr/0178-per-provider-custom-headers.md @@ -40,6 +40,16 @@ optional `headers` map in `config_json.headers`. `host`, `content-type`, `content-length`, `cookie`, `set-cookie`, `connection`, `transfer-encoding`, `te`, `trailer`, `upgrade`, `keep-alive`, `x-api-key`, `api-key`, `chatgpt-account-id`, `x-opencode-session`. +- Values are folded to half-width before that validation, and again on read: + the fullwidth block (U+FF01–U+FF5E) and the ideographic space (U+3000) become + their ASCII counterparts, because that is what an IME or a fullwidth-formatted + page produces for plain ASCII. What remains must be HTAB, printable ASCII or + the Latin-1 supplement — a header value is a ByteString, and Han text, emoji or + a control character makes `Headers.set` throw `Cannot convert argument to a + ByteString` mid-turn. The interactive write refuses those with the character + and its index named; the read path and an incoming sync bundle fold and drop + them, so a store or a peer that predates the rule cannot fail a turn or a + whole revision. See D621. - Not a secret. No SQLite or host-protocol version bump. - UI is an explicit Advanced settings button in the upper-right dialog actions (named, custom, and vendor account) that opens a separate compact modal. The diff --git a/docs/spec/03-runtime/12-provider-config-schema.md b/docs/spec/03-runtime/12-provider-config-schema.md index 22d79ddb25..7a7e95b9e8 100644 --- a/docs/spec/03-runtime/12-provider-config-schema.md +++ b/docs/spec/03-runtime/12-provider-config-schema.md @@ -255,7 +255,28 @@ OAuth token refresh. A fetch wrapper is the last writer so Codex and the Anthropic SDK cannot overwrite it. The same values are also placed on stream- option headers so OpenCode's caller-wins rule stays true. Keys are case-insensitive unique, at most 32 entries, name ≤ 256 bytes, value ≤ 4096 -bytes, no CR/LF, names alphanumeric plus hyphen. Reserved keys +bytes, no CR/LF, names alphanumeric plus hyphen. Values are folded to +half-width first — the fullwidth block (U+FF01–U+FF5E) and the ideographic +space (U+3000) become their ASCII counterparts — then trimmed, then checked: +HTAB, printable ASCII and the Latin-1 supplement may travel, while Han, emoji, +curly quotes, NUL and every other control character are refused with +`HEADERS_INVALID` naming the character and its index. Folding is what covers +the case users actually hit: a fullwidth character is what an IME or a +fullwidth-formatted page produces, and an unfixed value makes `Headers.set` +throw `Cannot convert argument to a ByteString` mid-turn. A full NFKC pass is +deliberately not used — it would rewrite halfwidth katakana into code points +above U+00FF and produce combining marks. The Advanced editor also says, next +to the rows, when a value will be folded and when it will be refused. + +The same rule is applied at three boundaries with three different failure +modes, deliberately: **the editor's save** refuses an unusable row with the +character and its index (`HEADERS_INVALID`), because a user is there to fix it; +**a stored map** is folded on read and the unusable rows dropped, so a store +written before the rule cannot fail a turn; and **a sync bundle** is folded and +dropped before it is deserialized into a write input, so a row a peer on an +older build (or a pre-rule backup) still carries cannot fail a whole revision. +The read path and the sync path therefore agree, and only the interactive write +reports an error. Reserved keys (`authorization`, `proxy-authorization`, `host`, `content-type`, `content-length`, `cookie`, `set-cookie`, `connection`, `transfer-encoding`, `te`, `trailer`, `upgrade`, `keep-alive`, `x-api-key`, `api-key`, @@ -595,10 +616,17 @@ The canonical DDL lives in [04-data-storage](04-data-storage.md) (D086). Summary 3. `apiStyle=opencode_go` requires the fixed OpenCode Go name and endpoint; clients must not accept overrides 4. `authKind=none` forbidden for cloud presets that require keys 5. headers keys are case-insensitive unique, at most 32 entries; names - alphanumeric plus hyphen; values trimmed, at most 4096 bytes, no CR/LF + alphanumeric plus hyphen; values folded from fullwidth to half-width, then + trimmed, at most 4096 bytes, no CR/LF, printable Latin-1 only — a character + above U+00FF or a control character is refused with the character and its + index named 6. reserved header names (`authorization`, `host`, `content-type`, `x-api-key`, `x-opencode-session`, and the rest listed above) are rejected -7. secretValue max length enforced (e.g. 8KB) +7. secretValue max length enforced (e.g. 8KB); a fullwidth value folds to + half-width on write and on read, because the key is signed into an HTTP + header. A key that is still not Latin-1 is **not** refused: some auth kinds + do not put the key in a header (a query parameter, a SigV4 signature), so + the writer cannot know. Such a key keeps failing at request time. 8. modelId must be non-empty trimmed string; allow `/`, `.`, `:`, `-` 9. unknown protocol on older clients => provider shown disabled with warning, not crash 10. Legacy `supportsReasoning`, when present, must still validate as boolean but diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index a68c1acaed..b653360790 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -796,8 +796,13 @@ identify the platform validation still needed. defaults return. 5) Edit the OAuth account Advanced headers, save, then run a turn that refreshes the access token. 6) Repeat against OpenCode Go and confirm `x-opencode-session` is still present. 7) Repeat against - Codex/Anthropic OAuth inference. 8) Attempt `Authorization` and CR/LF - values; save is rejected. + Codex/Anthropic OAuth inference. 8) Enter a value holding fullwidth + characters (`0`, `123`) and confirm the editor says it will be saved as + half-width, then save and reopen: the stored value is ASCII. 9) Enter a value + holding Han text or a NUL and confirm the editor warns before saving, then + save: it is rejected as `HEADERS_INVALID` with the character and its index + named. 10) Point the app at a store whose row was written before this rule and + run a turn. 11) Attempt `Authorization` and CR/LF values; save is rejected. - **Expected**: Non-empty custom headers are the last writer on that row's outbound HTTP (turns, subagents, one-shots, discovery, connection test, OAuth refresh). Empty restores pi-ai / `claude-cli` / OpenCode defaults. @@ -811,7 +816,11 @@ identify the platform validation still needed. OpenCode still sends `x-opencode-session` and `x-opencode-client`. Codex and Anthropic still send the custom User-Agent despite adapter last-writes. - First OAuth login does not collect headers. Reserved keys and CR/LF are + First OAuth login does not collect headers. A fullwidth value saves as + half-width and is folded again on read, so a row stored before this rule runs + a turn instead of throwing. A value holding Han text or a control character is + refused at save with the character and index named, and dropped by the runtime + rather than thrown by `Headers.set`. Reserved keys and CR/LF are rejected. Advanced is a compact key/value editor, not a lone User-Agent field. - **Specs linked**: `03-runtime/12-provider-config-schema.md`, diff --git a/docs/spec/08-meta/decisions-log.md b/docs/spec/08-meta/decisions-log.md index 8559c90cb6..d539f62a5d 100644 --- a/docs/spec/08-meta/decisions-log.md +++ b/docs/spec/08-meta/decisions-log.md @@ -6946,3 +6946,45 @@ must keep splitting are covered by `markdown-blocks.test.mjs`. - Only those three are converted. pi's collector reads nothing else, so `Grep`, `Glob`, `Bash`, plugin and MCP names keep the spelling we register, and the summarized text changes only where pi consumes the name. + +## 2026-09-22 — Provider header values fold fullwidth input instead of failing the turn (D621) + +- A custom provider header value holding a fullwidth character — `0` (U+FF10) + is what an IME or a fullwidth-formatted page gives for `0` — reached + `Headers.set` and made undici throw `TypeError: Cannot convert argument to a + ByteString because the character at index N has a value of X which is greater + than 255`. The request never left, the text named no field the user could fix, + and rows created before custom headers shipped could not show it: the same + configuration looked healthy on an older build. +- Header values now fold the fullwidth block (U+FF01–U+FF5E) and the ideographic + space (U+3000) onto ASCII, then trim, then require HTAB, printable ASCII or the + Latin-1 supplement. A full NFKC pass is deliberately not used: it rewrites + halfwidth katakana into U+30A2 plus combining marks, which still cannot travel. + Host persistence refuses what remains with `HEADERS_INVALID` naming the + character and its index; the runtime drops that row instead of throwing, the + same way it already drops CR/LF and reserved keys. +- The fold runs on read as well as on write, so a value stored before the rule + existed starts working after an upgrade rather than failing until the user + retypes it. Provider API keys fold on both sides too — a key is signed into + `Authorization` or `x-api-key`, where a fullwidth character can never be + correct — but a key is never refused at save, because some endpoints still + take it in a query parameter. +- The Advanced header editor says next to the rows when a value will be folded + and when it will be refused, so the outcome does not arrive as a surprise + error. One rule, two engines: `packages/shared/src/header-value.ts` and its + Rust mirror in `crates/host-core/src/providers/validation.rs`. See + `03-runtime/12-provider-config-schema.md`, ADR 0178, E2E-005G. +- The three boundaries where the rule runs fail differently on purpose, and the + difference is the point: the editor's save refuses an unusable row and names + the character, because a user is there to fix it; a stored map folds and drops + on read; and a sync bundle folds and drops before it is deserialized into a + write input, so a row a peer on an older build (or a pre-rule backup) still + carries cannot fail a whole revision. Without that third boundary the stricter + write turned one stale row into a permanently stuck sync — the read path would + have hidden it while the write path aborted on it. +- Provider API keys fold on both sides too — a key is signed into `Authorization` + or `x-api-key`, where a fullwidth character can never be correct — but a key is + never refused at save, because some auth kinds do not put the key in a header + (a query parameter, a SigV4 signature). A key that is still not Latin-1 keeps + failing at request time; refusing it would block a save this writer cannot + judge. diff --git a/docs/zh-CN/spec/03-runtime/12-provider-config-schema.md b/docs/zh-CN/spec/03-runtime/12-provider-config-schema.md index f523a44a63..777fd13a36 100644 --- a/docs/zh-CN/spec/03-runtime/12-provider-config-schema.md +++ b/docs/zh-CN/spec/03-runtime/12-provider-config-schema.md @@ -231,6 +231,10 @@ OpenCode Go(以及任何 `opencode.ai` 主机)的 LLM 请求必须带稳定 每行(AI 服务或 OAuth 账户)可在高级选项中用键值行编辑自定义请求头。空映射保持 pi-ai / `claude-cli` / OpenCode 默认。fetch 包装器是最后写入者,因此 Codex 与 Anthropic SDK 无法覆盖。禁止 `Authorization` / `Host` / `Content-Type` 等保留头。遗留的 `userAgent` 读取时迁入 `headers["User-Agent"]`。首次 OAuth 登录不收集请求头,登录后再编辑。覆盖 Anthropic OAuth 的 `claude-cli/…` 可能导致 Claude Pro/Max 拒绝请求。 +键不区分大小写且唯一,最多 32 条,名称 ≤ 256 字节,值 ≤ 4096 字节,名称只允许字母数字与连字符,且不得含 CR/LF。值先做半角化——全角块(U+FF01–U+FF5E)与表意空格(U+3000)换成对应 ASCII——再修剪,再校验:HTAB、可打印 ASCII 与 Latin-1 补充区可以随请求发出,汉字、emoji、弯引号、NUL 及其它控制字符则以 `HEADERS_INVALID` 拒绝,并指出具体字符与字符下标。半角化覆盖的正是用户真正会撞上的情况:全角字符来自输入法或全角排版的网页,若不处理,`Headers.set` 会在回合中途抛 `Cannot convert argument to a ByteString`。这里刻意不做完整 NFKC:它会把半角片假名改写成 U+00FF 以上的码位并产生组合字符。高级编辑器也会在行旁提示哪些值会被半角化、哪些会被拒绝。 + +同一条规则在三个边界上以三种**有意不同**的失败方式生效:**编辑器保存**时对无法发送的行报 `HEADERS_INVALID` 并指出字符与下标,因为此时有用户在场可以改;**读取已存映射**时做半角化并丢弃无法发送的行,规则生效前写入的数据不会让回合失败;**收到同步 bundle** 时在反序列化成写入输入之前先做半角化与丢弃,因此旧版本对端(或规则前的备份)仍带着的某一行不会让整个 revision 失败。也就是说读取路径与同步路径一致,只有交互式写入会报错。 + ### 命名端点预设 这些行由添加提供商对话框的**服务**下拉框创建。命名服务的常见路径是服务 + @@ -434,8 +438,13 @@ Copilot 的上下文相关请求标头;已保存的同名自定义 header 会 2. `openai_compatible` / 本地网关需要绝对 `baseUrl`,除非预设表示可选 3. `authKind=none` 禁止用于需要密钥的云预设 4. headers key 不区分大小写,唯一 -5. headers key 不区分大小写且唯一,最多 32 条;禁止保留头与 CR/LF -6. 强制实施 SecretValue 最大长度(例如 8KB) +5. headers key 不区分大小写且唯一,最多 32 条;名称只允许字母数字与连字符; + 值先由全角折成半角再修剪,最多 4096 字节,不得含 CR/LF,只能是可打印 + Latin-1——U+00FF 以上的字符或控制字符会被拒绝,并指出该字符与字符下标 +6. 强制实施 SecretValue 最大长度(例如 8KB);全角的值在写入与读取时都折成 + 半角,因为密钥最终会签进 HTTP 头。仍然不是 Latin-1 的密钥**不会**被拒绝: + 有些认证方式并不把密钥放进请求头(查询参数、SigV4 签名),写入侧无从判断, + 这类密钥仍在发请求时报错 6. modelId 必须是非空的修剪字符串;允许 `/`、`.`、`:`、`-` 7.旧客户端上的未知协议 => 提供程序显示为禁用并带有警告,而不是崩溃 8. 旧版 `supportsReasoning`(如果存在)仍必须验证为布尔值,但 diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index 6c1c66c2ba..565bd0436e 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -349,8 +349,8 @@ task-candidate E2E 从请求工作树运行,但使用主工作区已经准备 #### E2E-005G:按供应商自定义 HTTP 请求头 - **前提条件**:一个 API 密钥 AI 服务(含 OpenCode Go)和一个已登录的厂商 OAuth 账户。 -- **步骤**:打开高级设置弹框,确认只有标题栏关闭按钮而没有底部操作行;用常用预设添加 User-Agent,导入超过五行可见区域的 JSON,再复制请求头 JSON;确认列表最多显示五行,更多请求头在自身区域滚动;确认剪贴板是与持久化相同的规范化对象(忽略空名称,后者覆盖前者),并有本地化成功反馈。 -- **预期**:复制输出与保存到该行的 `pairsToRecord` 映射一致;导入仍接受两种 JSON 形状;窄宽度下工具栏换行而不溢出。空映射恢复适配器默认值。 +- **步骤**:打开高级设置弹框,确认只有标题栏关闭按钮而没有底部操作行;用常用预设添加 User-Agent,导入超过五行可见区域的 JSON,再复制请求头 JSON;确认列表最多显示五行,更多请求头在自身区域滚动;确认剪贴板是与持久化相同的规范化对象(忽略空名称,后者覆盖前者),并有本地化成功反馈。然后填入含全角字符的值(`0`、`123`),确认编辑器提示将按半角保存,保存后重新打开显示为 ASCII;再填入含汉字或 NUL 的值,确认保存前有提示、保存被拒并指出字符与下标;最后用规则生效前写入的数据打开一次回合。 +- **预期**:复制输出与保存到该行的 `pairsToRecord` 映射一致;导入仍接受两种 JSON 形状;窄宽度下工具栏换行而不溢出。空映射恢复适配器默认值。含全角字符的值保存为半角、读取时再次半角化,因此规则生效前存下的行仍能正常跑回合;含汉字或控制字符的值在保存时被拒(给出字符与字符下标),运行时直接丢弃而不是让 `Headers.set` 抛错。 - **链接规格**:`03-runtime/12-provider-config-schema.md`、`04-ux/06-settings-ia.md`、ADR 0178 - **验收**:B(模型配置) - **里程碑**:M2 diff --git a/docs/zh-CN/spec/08-meta/decisions-log.md b/docs/zh-CN/spec/08-meta/decisions-log.md index 7c9598d719..7ceb18b1f2 100644 --- a/docs/zh-CN/spec/08-meta/decisions-log.md +++ b/docs/zh-CN/spec/08-meta/decisions-log.md @@ -4915,3 +4915,31 @@ Markdown 源码,不是 `text/html` 负载;对禁用行内 HTML 的外部编 保留我们的拼写——而且当历史里没有这类调用时原样返回入参数组,常见路径不产生任何分配。 - 只转换这三个名字。pi 的收集器不读其它名字,因此 `Grep`、`Glob`、`Bash`、插件与 MCP 名字 保持我们注册的拼写,被摘要的文本只在 pi 真正消费该名字的地方发生变化。 + +## 2026-09-22 —— 供应商请求头里的全角字符改为折成半角,而不是让回合失败(D621) + +- 自定义供应商请求头的值里带一个全角字符——输入法或全角排版的网页会把 `0` 打成 `0` + (U+FF10)——这个值进到 `Headers.set` 后让 undici 抛 `TypeError: Cannot convert + argument to a ByteString because the character at index N has a value of X which + is greater than 255`。请求根本没发出去,报错文本不指向任何用户能改的字段,而且只有 + 在自定义请求头功能上线之后建的行才会出现:同一份配置在旧版本上看起来完全正常。 +- 现在值会先把全角块(U+FF01–U+FF5E)与表意空格(U+3000)折成 ASCII,再修剪,再要求 + 只能是 HTAB、可打印 ASCII 或 Latin-1 补充区。这里刻意不走完整 NFKC:它会把半角片假名 + 改写成 U+30A2 并附带组合字符,照样发不出去。剩余情况由宿主持久化层以 + `HEADERS_INVALID` 拒绝,并指出具体字符与字符下标;运行时遇到这类行直接丢弃而不是抛错, + 与它已对 CR/LF 和保留头采取的做法一致。 +- 半角化在写入与读取两侧都做,因此规则生效前存下的值升级后可直接使用,不必等用户重新 + 输入。 +- 这条规则生效的三个边界刻意采用不同的失败方式:编辑器保存时拒绝无法发送的行并指出字符, + 因为此时有用户在场可以改;读取已存映射时半角化并丢弃;收到同步 bundle 时在反序列化成 + 写入输入之前半角化并丢弃,因此旧版本对端(或规则前的备份)带着的某一行不会让整个 + revision 失败。少了第三个边界,更严格的写入会把一行陈旧数据变成永久卡住的同步—— + 读取路径把它藏起来,写入路径却在它上面直接报错。 +- 供应商 API 密钥同样两侧折叠——密钥最终签进 `Authorization` 或 `x-api-key`,全角字符 + 在那里永远不可能是对的——但密钥不会在保存时被拒绝,因为有些认证方式并不把密钥放进请求 + 头(查询参数、SigV4 签名)。仍然不是 Latin-1 的密钥继续在发请求时报错;拒绝它会拦住 + 一个写入侧无从判断的保存。 +- 高级请求头编辑器会在行旁说明哪些值会被折成半角、哪些会被拒绝,避免用户只能从报错里 + 得知结果。一份规则、两处实现:`packages/shared/src/header-value.ts` 与 + `crates/host-core/src/providers/validation.rs`。参见 + `03-runtime/12-provider-config-schema.md`、ADR 0178、E2E-005G。 diff --git a/packages/agent-runtime/src/provider-headers.test.ts b/packages/agent-runtime/src/provider-headers.test.ts index bb9ddff7b6..d120f5aa64 100644 --- a/packages/agent-runtime/src/provider-headers.test.ts +++ b/packages/agent-runtime/src/provider-headers.test.ts @@ -32,6 +32,20 @@ describe("normalizeProviderHeaders", () => { expect(normalizeProviderHeaders({ "X_Nope": "1" })).toBeUndefined(); }); + it("folds fullwidth values and drops what still cannot be a ByteString", () => { + expect( + normalizeProviderHeaders({ + "X-Title": "PI\u3000Desktop", + "X-Key": "1234567\uFF10", + "X-CJK": "星", + "X-Control": "ab\u0000cd", + }), + ).toEqual({ + "X-Title": "PI Desktop", + "X-Key": "12345670", + }); + }); + it("collapses duplicate keys case-insensitively, last write winning", () => { expect( normalizeProviderHeaders({ diff --git a/packages/agent-runtime/src/provider-headers.ts b/packages/agent-runtime/src/provider-headers.ts index 68b8d1da62..db71ad85d5 100644 --- a/packages/agent-runtime/src/provider-headers.ts +++ b/packages/agent-runtime/src/provider-headers.ts @@ -8,14 +8,21 @@ * * Empty / omitted keeps adapter defaults. Authorization, Host, Content-Type, * and other hop-by-hop or auth keys are rejected so this cannot smash signing. + * + * Values are folded to half-width and trimmed before they enter the map (see + * `@pi-desktop/shared`'s `header-value.ts`), and a value that still cannot be + * a ByteString is dropped here rather than thrown by `Headers.set` at request + * time. Host persistence rejects the same rows with a named error, so this + * path only sees a stale store, a plugin, or an unsaved form value. */ import { AsyncLocalStorage } from "node:async_hooks"; +import { HEADER_VALUE_MAX_BYTES, inspectHeaderValue } from "@pi-desktop/shared"; import type { FetchFunction, ProviderHeaders, SimpleStreamOptions } from "@earendil-works/pi-ai"; export const PROVIDER_HEADERS_MAX = 32; export const PROVIDER_HEADER_KEY_MAX_BYTES = 256; -export const PROVIDER_HEADER_VALUE_MAX_BYTES = 4096; +export const PROVIDER_HEADER_VALUE_MAX_BYTES = HEADER_VALUE_MAX_BYTES; const FORBIDDEN_HEADER_KEYS = new Set([ "authorization", @@ -74,7 +81,13 @@ function overlayHeaders( } } -/** Drop invalid rows. Host persistence rejects the same cases with an error. */ +/** + * Drop invalid rows. Host persistence rejects the same cases with an error. + * + * Fullwidth values fold to half-width; a value that still holds a character + * above U+00FF (or a control character) is dropped instead of reaching + * `Headers.set`, which would throw a ByteString TypeError mid-turn. + */ export function normalizeProviderHeaders( value: unknown, ): Record | undefined { @@ -83,12 +96,13 @@ export function normalizeProviderHeaders( for (const [rawKey, rawValue] of Object.entries(value as Record)) { if (typeof rawValue !== "string") continue; const key = rawKey.trim(); - const headerValue = rawValue.trim(); + const header = inspectHeaderValue(rawValue); + const headerValue = header.value; if (!key || !headerValue) continue; + if (header.fault) continue; if (key.length > PROVIDER_HEADER_KEY_MAX_BYTES) continue; if (headerValue.length > PROVIDER_HEADER_VALUE_MAX_BYTES) continue; if (key.includes("\r") || key.includes("\n")) continue; - if (headerValue.includes("\r") || headerValue.includes("\n")) continue; if (!validHeaderKey(key)) continue; const lower = key.toLowerCase(); if (FORBIDDEN_HEADER_KEYS.has(lower)) continue; diff --git a/packages/i18n/src/locales/de/index.ts b/packages/i18n/src/locales/de/index.ts index bf39f3bb53..cba85168e7 100644 --- a/packages/i18n/src/locales/de/index.ts +++ b/packages/i18n/src/locales/de/index.ts @@ -1300,6 +1300,8 @@ sklm: { "headersJsonCopied": "Header-JSON kopiert", "importHeadersJson": "JSON importieren", "headersImportError": "Verwenden Sie ein JSON-Objekt mit Header-Namen und Zeichenfolgenwerten.", + "headersFullwidthFolded": "Werte mit Vollbreiten-Zeichen werden halbbreit gespeichert.", + "headersValueNotLatin1": "Header-Werte müssen Latin-1 sein: chinesischer Text, Emoji und Ähnliches lassen sich nicht in einem HTTP-Header übertragen.", "changeDefaultModel": "Ändern", "presetCustomEndpoint": "Benutzerdefinierter Endpunkt", "customModelHint": "Fügen Sie eine ID hinzu, die der Katalog noch nicht veröffentlicht.", diff --git a/packages/i18n/src/locales/en/index.ts b/packages/i18n/src/locales/en/index.ts index 21f620a1ad..cac17ebe60 100644 --- a/packages/i18n/src/locales/en/index.ts +++ b/packages/i18n/src/locales/en/index.ts @@ -1319,6 +1319,8 @@ sklm: { headersJsonCopied: "Headers JSON copied", importHeadersJson: "Import JSON", headersImportError: "Use a JSON object with header names and string values.", + headersFullwidthFolded: "Fullwidth characters are saved as half-width.", + headersValueNotLatin1: "Header values must be Latin-1: Chinese text, emoji and the like cannot travel in an HTTP header.", changeDefaultModel: "Change", presetCustomEndpoint: "Custom endpoint", customModelHint: "Add an ID the catalog does not publish yet.", diff --git a/packages/i18n/src/locales/es/index.ts b/packages/i18n/src/locales/es/index.ts index 49f5a12146..75acfe3e00 100644 --- a/packages/i18n/src/locales/es/index.ts +++ b/packages/i18n/src/locales/es/index.ts @@ -1300,6 +1300,8 @@ sklm: { "headersJsonCopied": "Encabezados JSON copiados", "importHeadersJson": "Importar JSON", "headersImportError": "Utilice un objeto JSON con nombres de encabezado y valores de cadena.", + "headersFullwidthFolded": "Los valores con caracteres de ancho completo se guardan como de ancho medio.", + "headersValueNotLatin1": "Los valores de encabezado deben ser Latin-1: el texto en chino, los emoji y similares no pueden viajar en un encabezado HTTP.", "changeDefaultModel": "Cambiar", "presetCustomEndpoint": "Punto final personalizado", "customModelHint": "Agregue un ID que el catálogo aún no publica.", diff --git a/packages/i18n/src/locales/fr/index.ts b/packages/i18n/src/locales/fr/index.ts index c20f0092ca..a0b226bea6 100644 --- a/packages/i18n/src/locales/fr/index.ts +++ b/packages/i18n/src/locales/fr/index.ts @@ -1300,6 +1300,8 @@ sklm: { "headersJsonCopied": "En-têtes JSON copiés", "importHeadersJson": "Importer JSON", "headersImportError": "Utilisez un objet JSON avec des noms d'en-tête et des valeurs de chaîne.", + "headersFullwidthFolded": "Les valeurs contenant des caractères pleine largeur sont enregistrées en demi-largeur.", + "headersValueNotLatin1": "Les valeurs d'en-tête doivent être en Latin-1 : le texte chinois, les emoji et similaires ne peuvent pas être transmis dans un en-tête HTTP.", "changeDefaultModel": "Modifier", "presetCustomEndpoint": "Point de terminaison personnalisé", "customModelHint": "Ajoutez un identifiant que le catalogue n'a pas encore publié.", diff --git a/packages/i18n/src/locales/ko/index.ts b/packages/i18n/src/locales/ko/index.ts index 560eeabdb2..c5b9849b7f 100644 --- a/packages/i18n/src/locales/ko/index.ts +++ b/packages/i18n/src/locales/ko/index.ts @@ -1317,6 +1317,8 @@ sklm: { headersJsonCopied: "헤더 JSON 복사됨", importHeadersJson: "JSON 가져오기", headersImportError: "헤더 이름과 문자열 값을 가진 JSON 객체를 사용하세요.", + headersFullwidthFolded: "전각 문자가 포함된 값은 반각으로 저장됩니다.", + headersValueNotLatin1: "헤더 값은 Latin-1이어야 합니다. 한글, 이모지 등은 HTTP 헤더로 전송할 수 없습니다.", changeDefaultModel: "변경", presetCustomEndpoint: "사용자 지정 엔드포인트", customModelHint: "카탈로그에 아직 게시되지 않은 ID를 추가하세요.", diff --git a/packages/i18n/src/locales/tr/index.ts b/packages/i18n/src/locales/tr/index.ts index 43ae59fd86..4af2d1984c 100644 --- a/packages/i18n/src/locales/tr/index.ts +++ b/packages/i18n/src/locales/tr/index.ts @@ -1307,6 +1307,8 @@ sklm: { headersJsonCopied: "Başlıklar JSON olarak kopyalandı", importHeadersJson: "JSON içe aktar", headersImportError: "JSON, başlık adları ve metin değerlerinden oluşan bir nesne olmalıdır.", + headersFullwidthFolded: "Tam genişlikli karakter içeren değerler yarım genişlikte kaydedilir.", + headersValueNotLatin1: "Başlık değerleri Latin-1 olmalıdır: Çince metin, emoji ve benzerleri bir HTTP başlığında taşınamaz.", changeDefaultModel: "Değiştir", presetCustomEndpoint: "Özel uç nokta", customModelHint: "Katalogda henüz yayınlanmayan bir kimlik ekleyin.", diff --git a/packages/i18n/src/locales/zh-CN/index.ts b/packages/i18n/src/locales/zh-CN/index.ts index 7229205fbc..4769f19579 100644 --- a/packages/i18n/src/locales/zh-CN/index.ts +++ b/packages/i18n/src/locales/zh-CN/index.ts @@ -1287,6 +1287,8 @@ sklm: { headersJsonCopied: "请求头 JSON 已复制", importHeadersJson: "导入 JSON", headersImportError: "JSON 须是由请求头名称和字符串值组成的对象。", + headersFullwidthFolded: "含全角字符的值会按半角保存。", + headersValueNotLatin1: "请求头的值只能是 Latin-1:中文、emoji 等字符无法写入 HTTP 头,保存会被拒绝。", changeDefaultModel: "更改", presetCustomEndpoint: "自定义端点", customModelHint: "添加目录尚未发布的模型 ID。", diff --git a/packages/i18n/src/locales/zh-TW/index.ts b/packages/i18n/src/locales/zh-TW/index.ts index 6f191287b9..11b4b34fba 100644 --- a/packages/i18n/src/locales/zh-TW/index.ts +++ b/packages/i18n/src/locales/zh-TW/index.ts @@ -1287,6 +1287,8 @@ sklm: { headersJsonCopied: "已複製請求頭 JSON", importHeadersJson: "匯入 JSON", headersImportError: "JSON 須是由請求頭名稱和字串值組成的物件。", + headersFullwidthFolded: "含全形字元的值會以半形儲存。", + headersValueNotLatin1: "請求頭的值只能是 Latin-1:中文、emoji 等字元無法寫入 HTTP 標頭,儲存會被拒絕。", changeDefaultModel: "更改", presetCustomEndpoint: "自定義端點", customModelHint: "新增目錄尚未釋出的模型 ID。", diff --git a/packages/shared/src/header-value.test.ts b/packages/shared/src/header-value.test.ts new file mode 100644 index 0000000000..461239b01e --- /dev/null +++ b/packages/shared/src/header-value.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { firstHeaderValueFault, foldFullwidthHeaderValue, inspectHeaderValue } from "./header-value.js"; + +describe("foldFullwidthHeaderValue", () => { + it("folds the fullwidth block onto ASCII", () => { + // The reported failure shape: `0` is U+FF10, which lands on index 7 of + // `12345670` and made undici throw a ByteString TypeError. + expect(foldFullwidthHeaderValue("1234567\uFF10")).toBe("12345670"); + expect(foldFullwidthHeaderValue("\uFF10\uFF11\uFF41\uFF0D")).toBe("01a-"); + expect(foldFullwidthHeaderValue("\uFF08\uFF09\uFF0C")).toBe("(),"); + }); + + it("folds the ideographic space but keeps interior ASCII text", () => { + expect(foldFullwidthHeaderValue("PI\u3000Desktop")).toBe("PI Desktop"); + }); + + it("leaves halfwidth katakana alone", () => { + // NFKC would turn these into U+30A2 / U+3099 — still unusable. + expect(foldFullwidthHeaderValue("\uFF71\uFF9E")).toBe("\uFF71\uFF9E"); + }); + + it("keeps characters that have no ASCII counterpart", () => { + expect(foldFullwidthHeaderValue("星\u201C")).toBe("星\u201C"); + }); + + it("returns the input when nothing folds", () => { + const value = "Bearer sk-abc"; + expect(foldFullwidthHeaderValue(value)).toBe(value); + }); +}); + +describe("firstHeaderValueFault", () => { + it("accepts printable ASCII, HTAB, and Latin-1", () => { + expect(firstHeaderValueFault("")).toBeNull(); + expect(firstHeaderValueFault("pi-desktop/0.15.3")).toBeNull(); + expect(firstHeaderValueFault("a\tb")).toBeNull(); + expect(firstHeaderValueFault("caf\u00E9")).toBeNull(); + }); + + it("names the first unusable character and where it sits", () => { + expect(firstHeaderValueFault("0123456\uFF10")).toEqual({ + index: 7, + codePoint: 0xff10, + }); + expect(firstHeaderValueFault("abc星")).toEqual({ index: 3, codePoint: 0x661f }); + expect(firstHeaderValueFault("ab\u0000cd")).toEqual({ index: 2, codePoint: 0 }); + expect(firstHeaderValueFault("a\r\nb")).toEqual({ index: 1, codePoint: 0x0d }); + expect(firstHeaderValueFault("trailing\u007F")).toEqual({ + index: 8, + codePoint: 0x7f, + }); + }); +}); + +describe("inspectHeaderValue", () => { + it("reports the folded value a request should carry", () => { + expect(inspectHeaderValue(" \uFF10 7星 ")).toEqual({ + value: "0 7星", + folded: true, + fault: { index: 4, codePoint: 0x661f }, + }); + }); + + it("folds a half-width-style header preset", () => { + expect(inspectHeaderValue(" X-Title: \uFF10 ")).toEqual({ + value: "X-Title: 0", + folded: true, + fault: null, + }); + }); + + it("marks an untouched value as not folded", () => { + expect(inspectHeaderValue(" pi-desktop ")).toEqual({ + value: "pi-desktop", + folded: false, + fault: null, + }); + }); + + it("reports the folded value's fault, not the raw input's", () => { + // U+FF10 folds to `0`, so the remaining fault is the Han character. + expect(inspectHeaderValue("\uFF10星")).toEqual({ + value: "0星", + folded: true, + fault: { index: 1, codePoint: 0x661f }, + }); + }); +}); + +describe("trim", () => { + it("strips a pasted byte-order mark, as the host does", () => { + // U+FEFF is invisible and arrives with values copied from UTF-8 files. + // Both engines must drop it: otherwise the editor would show a clean value + // while the host refuses it as a non-Latin-1 character. + expect(inspectHeaderValue("\uFEFFX-Title\uFEFF")).toEqual({ + value: "X-Title", + folded: false, + fault: null, + }); + }); + + it("keeps U+0085, which JavaScript does not treat as whitespace", () => { + // It travels: the Latin-1 supplement is allowed in a header value, so the + // host has to store the same bytes the runtime would have sent. + expect(inspectHeaderValue("\u0085abc\u0085")).toEqual({ + value: "\u0085abc\u0085", + folded: false, + fault: null, + }); + }); +}); diff --git a/packages/shared/src/header-value.ts b/packages/shared/src/header-value.ts new file mode 100644 index 0000000000..1ac1407262 --- /dev/null +++ b/packages/shared/src/header-value.ts @@ -0,0 +1,110 @@ +/** + * HTTP header values travel as ByteStrings: every UTF-16 code unit must be + * ≤ U+00FF, and undici (Node's `fetch`) rejects anything else with + * `TypeError: Cannot convert argument to a ByteString because the character at + * index N has a value of X which is greater than 255.` — a message a user + * cannot act on. + * + * Two different inputs get fixed differently: + * + * - The fullwidth block (U+FF01–U+FF5E) and the ideographic space (U+3000) + * are what a Chinese/Japanese IME or a fullwidth-formatted page produces for + * plain ASCII. `0` is U+FF10, so it folds back to `0`, which is what the + * user meant. This is the compatibility case. + * - Everything else above U+00FF (Han, emoji, curly quotes) has no wire + * representation in a header value; percent- or base64-encoding would send + * the server bytes it never decodes. Those are rejected by the callers. + * + * Only the fullwidth block is folded. A full NFKC pass is deliberately not + * used: NFKC would also rewrite halfwidth katakana `ア` into U+30A2 and emit + * combining marks, swapping one unusable value for another. + */ + +/** + * Upper bound for one value. The runtime measures UTF-16 code units here (its + * name predates this module) while host validation measures UTF-8 bytes, so the + * host bound is the stricter one — a value the runtime accepts can still be + * refused on save, never the other way round. + */ +export const HEADER_VALUE_MAX_BYTES = 4096; + +export const HEADER_VALUE_FULLWIDTH_FIRST = 0xff01; +export const HEADER_VALUE_FULLWIDTH_LAST = 0xff5e; +export const HEADER_VALUE_FULLWIDTH_TO_ASCII = 0xfee0; +export const HEADER_VALUE_IDEOGRAPHIC_SPACE = 0x3000; + +/** + * What undici's `headerValueRegex` accepts: HTAB, printable ASCII, and the + * Latin-1 supplement. Anything else (NUL, other C0 controls, DEL, and every + * code point above U+00FF) fails at request time. + */ +export const HEADER_VALUE_ALLOWED = /^[\t\x20-\x7E\x80-\xFF]*$/; + +export type HeaderValueFault = { + /** Code-unit index in the folded value undici would have converted. */ + index: number; + codePoint: number; +}; + +export type HeaderValueInspection = { + /** Folded and trimmed text; what the caller should send. */ + value: string; + /** True when folding rewrote the input (it held fullwidth characters). */ + folded: boolean; + /** First character that cannot travel in a header value, if any. */ + fault: HeaderValueFault | null; +}; + +/** Fold the fullwidth block and the ideographic space onto ASCII. */ +export function foldFullwidthHeaderValue(value: string): string { + let out = ""; + let changed = false; + for (const character of value) { + const codePoint = character.codePointAt(0) ?? 0; + if ( + codePoint >= HEADER_VALUE_FULLWIDTH_FIRST && + codePoint <= HEADER_VALUE_FULLWIDTH_LAST + ) { + out += String.fromCharCode(codePoint - HEADER_VALUE_FULLWIDTH_TO_ASCII); + changed = true; + } else if (codePoint === HEADER_VALUE_IDEOGRAPHIC_SPACE) { + out += " "; + changed = true; + } else { + out += character; + } + } + return changed ? out : value; +} + +/** + * Fold, trim, and report whether the result can be sent. Callers decide what + * an unusable value means: the host rejects the save and names the character, + * the runtime drops the row so a stale store cannot blow up a request. + * + * The trim is `String.prototype.trim`, which is also what the host mirrors — + * including U+FEFF, a byte-order mark pasted from a file, and excluding U+0085. + */ +export function inspectHeaderValue(raw: string): HeaderValueInspection { + const foldedText = foldFullwidthHeaderValue(raw); + const value = foldedText.trim(); + const folded = foldedText !== raw; + const fault = firstHeaderValueFault(value); + return { value, folded, fault }; +} + +export function firstHeaderValueFault(value: string): HeaderValueFault | null { + if (HEADER_VALUE_ALLOWED.test(value)) return null; + for (let index = 0; index < value.length; index += 1) { + const codePoint = value.charCodeAt(index); + if ( + codePoint === 0x09 || + (codePoint >= 0x20 && codePoint <= 0x7e) || + (codePoint >= 0x80 && codePoint <= 0xff) + ) { + continue; + } + return { index, codePoint }; + } + return null; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 369d0a7cf6..2b6ab77235 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -65,3 +65,4 @@ export * from "./tray-sessions.js"; export * from "./window-chrome.js"; export * from "./prompt-enhancement.js"; export * from "./native-web-search.js"; +export * from "./header-value.js";