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