diff --git a/Cargo.lock b/Cargo.lock index ea01751de..b8913453d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3072,7 +3072,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "op-acp" -version = "0.8.0" +version = "0.8.1" dependencies = [ "futures-util", "op-ai", @@ -3086,11 +3086,11 @@ dependencies = [ [[package]] name = "op-ai" -version = "0.8.0" +version = "0.8.1" [[package]] name = "op-ai-skills" -version = "0.8.0" +version = "0.8.1" dependencies = [ "include_dir", "jian-ops-schema", @@ -3099,7 +3099,7 @@ dependencies = [ [[package]] name = "op-cli" -version = "0.8.0" +version = "0.8.1" dependencies = [ "base64", "op-ai-skills", @@ -3113,7 +3113,7 @@ dependencies = [ [[package]] name = "op-codegen" -version = "0.8.0" +version = "0.8.1" dependencies = [ "base64", "jian-ops-schema", @@ -3126,7 +3126,7 @@ dependencies = [ [[package]] name = "op-config-store" -version = "0.8.0" +version = "0.8.1" dependencies = [ "serde", "serde_json", @@ -3134,7 +3134,7 @@ dependencies = [ [[package]] name = "op-design-lint" -version = "0.8.0" +version = "0.8.1" dependencies = [ "jian-ops-schema", "serde", @@ -3143,7 +3143,7 @@ dependencies = [ [[package]] name = "op-editor-core" -version = "0.8.0" +version = "0.8.1" dependencies = [ "glam", "jian-core", @@ -3157,7 +3157,7 @@ dependencies = [ [[package]] name = "op-editor-host-core" -version = "0.8.0" +version = "0.8.1" dependencies = [ "jian-ops-schema", "op-ai", @@ -3170,7 +3170,7 @@ dependencies = [ [[package]] name = "op-editor-ui" -version = "0.8.0" +version = "0.8.1" dependencies = [ "accesskit", "base64", @@ -3188,7 +3188,7 @@ dependencies = [ [[package]] name = "op-figma" -version = "0.8.0" +version = "0.8.1" dependencies = [ "base64", "flate2", @@ -3200,7 +3200,7 @@ dependencies = [ [[package]] name = "op-git" -version = "0.8.0" +version = "0.8.1" dependencies = [ "git2", "op-config-store", @@ -3211,7 +3211,7 @@ dependencies = [ [[package]] name = "op-host-desktop" -version = "0.8.0" +version = "0.8.1" dependencies = [ "accesskit", "accesskit_macos", @@ -3266,7 +3266,7 @@ dependencies = [ [[package]] name = "op-host-native" -version = "0.8.0" +version = "0.8.1" dependencies = [ "accesskit", "casement", @@ -3294,7 +3294,7 @@ dependencies = [ [[package]] name = "op-host-services" -version = "0.8.0" +version = "0.8.1" dependencies = [ "agent", "anthropic-agent-sdk", @@ -3328,7 +3328,7 @@ dependencies = [ [[package]] name = "op-host-web" -version = "0.8.0" +version = "0.8.1" dependencies = [ "accesskit", "base64", @@ -3353,18 +3353,18 @@ dependencies = [ [[package]] name = "op-host-web-server" -version = "0.8.0" +version = "0.8.1" dependencies = [ "op-host-services", ] [[package]] name = "op-i18n" -version = "0.8.0" +version = "0.8.1" [[package]] name = "op-mcp" -version = "0.8.0" +version = "0.8.1" dependencies = [ "jian-ops-schema", "jian-scene", @@ -3380,7 +3380,7 @@ dependencies = [ [[package]] name = "op-opmerge" -version = "0.8.0" +version = "0.8.1" dependencies = [ "serde_json", "thiserror 1.0.69", @@ -3388,7 +3388,7 @@ dependencies = [ [[package]] name = "op-orchestrator" -version = "0.8.0" +version = "0.8.1" dependencies = [ "futures", "jian-ops-schema", @@ -3406,7 +3406,7 @@ dependencies = [ [[package]] name = "op-pen-loader" -version = "0.8.0" +version = "0.8.1" dependencies = [ "jian-core", "jian-ops-schema", @@ -3420,14 +3420,14 @@ dependencies = [ [[package]] name = "op-process-io" -version = "0.8.0" +version = "0.8.1" dependencies = [ "tokio", ] [[package]] name = "op-rpc-transport" -version = "0.8.0" +version = "0.8.1" dependencies = [ "serde", "serde_json", @@ -3435,7 +3435,7 @@ dependencies = [ [[package]] name = "op-smoke" -version = "0.8.0" +version = "0.8.1" dependencies = [ "agent", "futures", @@ -3454,7 +3454,7 @@ dependencies = [ [[package]] name = "op-web-sdk" -version = "0.8.0" +version = "0.8.1" dependencies = [ "console_error_panic_hook", "jian-ops-schema", diff --git a/Cargo.toml b/Cargo.toml index 5a288a890..eb40de119 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ exclude = [ default-members = ["crates/op-host-desktop", "crates/op-cli", "crates/op-host-web-server"] [workspace.package] -version = "0.8.0" +version = "0.8.1" edition = "2021" # 1.87 floor: op-mcp's `script` feature (enabled unconditionally by # op-host-services + op-orchestrator) depends on rquickjs 0.12, whose diff --git a/README.de.md b/README.de.md index ea0a32f57..9b7c672da 100644 --- a/README.de.md +++ b/README.de.md @@ -123,14 +123,14 @@ Getaggte Rust-Releases veröffentlichen ein einzelnes Web-Host-Image. Die frühe | Image | Enthält | | --- | --- | -| `ghcr.io/zseven-w/openpencil-web:v0.8.0` | Rust-Web-Host, wasm bundle und CanvasKit-Assets | +| `ghcr.io/zseven-w/openpencil-web:v0.8.1` | Rust-Web-Host, wasm bundle und CanvasKit-Assets | Die Web-UI zeigt nur integrierte Agent-Profile; Claude/Codex/OpenCode/Copilot/Gemini-CLI-Tools sind nicht in Docker-Images enthalten. **Ausführen:** ```bash -docker run -d -p 3100:3100 ghcr.io/zseven-w/openpencil-web:v0.8.0 +docker run -d -p 3100:3100 ghcr.io/zseven-w/openpencil-web:v0.8.1 ``` Öffnen Sie dann `http://localhost:3100/`. @@ -308,7 +308,7 @@ OpenPencil wurde von Grund auf in **Rust** neu geschrieben ([#129](https://githu - **Native Barrierefreiheit** — AccessKit auf macOS, Windows und Linux sowie ein DOM-Spiegel im Web, anstatt sich auf den a11y-Baum eines Browsers zu stützen. - **Ein typgeprüfter Workspace** — der MCP-Host, CLI, AI-Anbieter, Codegenerierung, Figma-Import und Git-Integration leben alle in einem einzigen Rust-Workspace, mit `cargo-deny`-Supply-Chain-Kontrolle in der CI. -> **Status:** der TypeScript-Editor wurde mit `v0.7.5` eingestellt und existiert nur noch in der Git-Historie; dieses Repository ist der Rust-Workspace. Das `v0.8.0`-Rust-Release befindet sich in aktiver Entwicklung (siehe Roadmap unten). +> **Status:** der TypeScript-Editor wurde mit `v0.7.5` eingestellt und existiert nur noch in der Git-Historie; dieses Repository ist der Rust-Workspace. Das `v0.8.1`-Rust-Release befindet sich in aktiver Entwicklung (siehe Roadmap unten). ## Projektstruktur diff --git a/README.es.md b/README.es.md index 9fee3fd1d..cf039a635 100644 --- a/README.es.md +++ b/README.es.md @@ -123,14 +123,14 @@ Las releases Rust etiquetadas publican una única imagen de web host. Las imáge | Imagen | Incluye | | --- | --- | -| `ghcr.io/zseven-w/openpencil-web:v0.8.0` | Rust web host, wasm bundle y recursos de CanvasKit | +| `ghcr.io/zseven-w/openpencil-web:v0.8.1` | Rust web host, wasm bundle y recursos de CanvasKit | La UI web expone solo perfiles de agente integrados; las herramientas Claude/Codex/OpenCode/Copilot/Gemini CLI no se incluyen en las imágenes Docker. **Ejecutar:** ```bash -docker run -d -p 3100:3100 ghcr.io/zseven-w/openpencil-web:v0.8.0 +docker run -d -p 3100:3100 ghcr.io/zseven-w/openpencil-web:v0.8.1 ``` Luego abre `http://localhost:3100/`. @@ -308,7 +308,7 @@ OpenPencil fue reescrito desde cero en **Rust** ([#129](https://github.com/ZSeve - **Accesibilidad nativa** — AccessKit en macOS, Windows y Linux, más un espejo DOM en web, en lugar de depender del árbol de accesibilidad del navegador. - **Un workspace con tipado estricto** — el host MCP, CLI, proveedores de AI, generación de código, importación de Figma y la integración con Git conviven en un único workspace Rust, con `cargo-deny` vigilando la cadena de suministro en CI. -> **Estado:** el editor TypeScript se retiró en `v0.7.5` y solo vive en el historial de git; este repositorio es el workspace Rust. La versión `v0.8.0` de Rust está en desarrollo activo (consulta la Hoja de Ruta más abajo). +> **Estado:** el editor TypeScript se retiró en `v0.7.5` y solo vive en el historial de git; este repositorio es el workspace Rust. La versión `v0.8.1` de Rust está en desarrollo activo (consulta la Hoja de Ruta más abajo). ## Estructura del Proyecto diff --git a/README.fr.md b/README.fr.md index 5bbb1682c..ab1f6e282 100644 --- a/README.fr.md +++ b/README.fr.md @@ -123,14 +123,14 @@ Les releases Rust taguées publient une seule image web host. Les anciennes imag | Image | Contenu | | --- | --- | -| `ghcr.io/zseven-w/openpencil-web:v0.8.0` | Rust web host, wasm bundle et ressources CanvasKit | +| `ghcr.io/zseven-w/openpencil-web:v0.8.1` | Rust web host, wasm bundle et ressources CanvasKit | L'interface web expose uniquement les profils d'agents intégrés ; les outils Claude/Codex/OpenCode/Copilot/Gemini CLI ne sont pas inclus dans les images Docker. **Exécuter :** ```bash -docker run -d -p 3100:3100 ghcr.io/zseven-w/openpencil-web:v0.8.0 +docker run -d -p 3100:3100 ghcr.io/zseven-w/openpencil-web:v0.8.1 ``` Ouvrez ensuite `http://localhost:3100/`. @@ -308,7 +308,7 @@ OpenPencil a été réécrit intégralement en **Rust** ([#129](https://github.c - **Accessibilité native** — AccessKit sur macOS, Windows et Linux, plus un miroir DOM sur le web, plutôt que de s'appuyer sur l'arbre a11y d'un navigateur. - **Un workspace avec vérification de types** — l'hôte MCP, le CLI, les fournisseurs AI, la génération de code, l'import Figma et l'intégration Git résident tous dans un seul workspace Rust, avec filtrage de la chaîne d'approvisionnement par `cargo-deny` en CI. -> **Statut :** l'éditeur TypeScript a été retiré en `v0.7.5` et ne subsiste que dans l'historique Git ; ce dépôt est le workspace Rust. La version Rust `v0.8.0` est en développement actif (voir la Feuille de route ci-dessous). +> **Statut :** l'éditeur TypeScript a été retiré en `v0.7.5` et ne subsiste que dans l'historique Git ; ce dépôt est le workspace Rust. La version Rust `v0.8.1` est en développement actif (voir la Feuille de route ci-dessous). ## Structure du projet diff --git a/README.hi.md b/README.hi.md index 0eb0cc2fe..72e855180 100644 --- a/README.hi.md +++ b/README.hi.md @@ -123,14 +123,14 @@ cargo run -p op-host-desktop | इमेज | शामिल | | --- | --- | -| `ghcr.io/zseven-w/openpencil-web:v0.8.0` | Rust web host, wasm bundle और CanvasKit assets | +| `ghcr.io/zseven-w/openpencil-web:v0.8.1` | Rust web host, wasm bundle और CanvasKit assets | Web UI केवल built-in agent profiles दिखाता है; Claude/Codex/OpenCode/Copilot/Gemini CLI tools Docker images में शामिल नहीं हैं। **चलाएँ:** ```bash -docker run -d -p 3100:3100 ghcr.io/zseven-w/openpencil-web:v0.8.0 +docker run -d -p 3100:3100 ghcr.io/zseven-w/openpencil-web:v0.8.1 ``` फिर `http://localhost:3100/` खोलें। @@ -308,7 +308,7 @@ OpenPencil को पूरी तरह **Rust** में नए सिरे - **नेटिव एक्सेसिबिलिटी** — macOS, Windows और Linux पर AccessKit, साथ ही वेब पर DOM मिरर — ब्राउज़र के a11y ट्री पर निर्भर रहने के बजाय। - **एक टाइप-चेक्ड वर्कस्पेस** — MCP होस्ट, CLI, AI प्रदाता, कोड जनरेशन, Figma इम्पोर्ट और Git इंटीग्रेशन सभी एक ही Rust वर्कस्पेस में रहते हैं, CI में `cargo-deny` सप्लाई-चेन गेटिंग के साथ। -> **स्थिति:** TypeScript एडिटर को `v0.7.5` पर रिटायर कर दिया गया था और अब यह केवल git इतिहास में मौजूद है; यह रिपॉज़िटरी अब Rust वर्कस्पेस है। `v0.8.0` Rust रिलीज़ सक्रिय विकास में है (नीचे रोडमैप देखें)। +> **स्थिति:** TypeScript एडिटर को `v0.7.5` पर रिटायर कर दिया गया था और अब यह केवल git इतिहास में मौजूद है; यह रिपॉज़िटरी अब Rust वर्कस्पेस है। `v0.8.1` Rust रिलीज़ सक्रिय विकास में है (नीचे रोडमैप देखें)। ## प्रोजेक्ट संरचना diff --git a/README.id.md b/README.id.md index e5e44f41a..f7b32984c 100644 --- a/README.id.md +++ b/README.id.md @@ -123,14 +123,14 @@ Rilis Rust bertag menerbitkan satu image web host. Image TypeScript lama yang me | Image | Termasuk | | --- | --- | -| `ghcr.io/zseven-w/openpencil-web:v0.8.0` | Rust web host, wasm bundle, dan aset CanvasKit | +| `ghcr.io/zseven-w/openpencil-web:v0.8.1` | Rust web host, wasm bundle, dan aset CanvasKit | Web UI hanya menampilkan built-in agent profiles; alat Claude/Codex/OpenCode/Copilot/Gemini CLI tidak dibundel ke dalam image Docker. **Jalankan:** ```bash -docker run -d -p 3100:3100 ghcr.io/zseven-w/openpencil-web:v0.8.0 +docker run -d -p 3100:3100 ghcr.io/zseven-w/openpencil-web:v0.8.1 ``` Lalu buka `http://localhost:3100/`. @@ -308,7 +308,7 @@ OpenPencil telah ditulis ulang dari awal dalam **Rust** ([#129](https://github.c - **Aksesibilitas native** — AccessKit di macOS, Windows, dan Linux, ditambah mirror DOM di web, alih-alih mengandalkan pohon a11y browser. - **Satu workspace bertipe** — host MCP, CLI, penyedia AI, pembuatan kode, impor Figma, dan integrasi Git semuanya berada dalam satu workspace Rust, dengan penjagaan rantai pasokan `cargo-deny` di CI. -> **Status:** editor TypeScript dipensiunkan pada `v0.7.5` dan kini hanya ada dalam riwayat git; repositori ini adalah workspace Rust. Rilis Rust `v0.8.0` sedang dalam pengembangan aktif (lihat Peta Jalan di bawah). +> **Status:** editor TypeScript dipensiunkan pada `v0.7.5` dan kini hanya ada dalam riwayat git; repositori ini adalah workspace Rust. Rilis Rust `v0.8.1` sedang dalam pengembangan aktif (lihat Peta Jalan di bawah). ## Struktur Proyek diff --git a/README.ja.md b/README.ja.md index 1861a20c8..3d619a93e 100644 --- a/README.ja.md +++ b/README.ja.md @@ -123,14 +123,14 @@ cargo run -p op-host-desktop | イメージ | 含まれるもの | | --- | --- | -| `ghcr.io/zseven-w/openpencil-web:v0.8.0` | Rust web host、wasm bundle、CanvasKit assets | +| `ghcr.io/zseven-w/openpencil-web:v0.8.1` | Rust web host、wasm bundle、CanvasKit assets | Web UI は built-in agent profiles のみを公開します。Claude/Codex/OpenCode/Copilot/Gemini CLI ツールは Docker イメージに同梱されません。 **実行:** ```bash -docker run -d -p 3100:3100 ghcr.io/zseven-w/openpencil-web:v0.8.0 +docker run -d -p 3100:3100 ghcr.io/zseven-w/openpencil-web:v0.8.1 ``` その後 `http://localhost:3100/` を開いてください。 @@ -308,7 +308,7 @@ OpenPencil は **Rust** で一から書き直されました ([#129](https://git - **ネイティブアクセシビリティ** — macOS・Windows・Linux では AccessKit を使用し、Web ではブラウザの a11y ツリーに依存する代わりに DOM ミラーを提供。 - **型チェック済みの単一ワークスペース** — MCP ホスト、CLI、AI プロバイダー、コード生成、Figma インポート、Git 統合がすべて単一の Rust ワークスペースに収まり、CI では `cargo-deny` によるサプライチェーンのゲーティングを実施。 -> **ステータス:** TypeScript エディターは `v0.7.5` で廃止され、現在は Git 履歴にのみ残っています。このリポジトリは Rust ワークスペースです。`v0.8.0` の Rust リリースは現在活発に開発中です(下記のロードマップを参照)。 +> **ステータス:** TypeScript エディターは `v0.7.5` で廃止され、現在は Git 履歴にのみ残っています。このリポジトリは Rust ワークスペースです。`v0.8.1` の Rust リリースは現在活発に開発中です(下記のロードマップを参照)。 ## プロジェクト構成 diff --git a/README.ko.md b/README.ko.md index 3f91cd053..897d6dd9d 100644 --- a/README.ko.md +++ b/README.ko.md @@ -123,14 +123,14 @@ cargo run -p op-host-desktop | 이미지 | 포함 내용 | | --- | --- | -| `ghcr.io/zseven-w/openpencil-web:v0.8.0` | Rust web host, wasm bundle, CanvasKit assets | +| `ghcr.io/zseven-w/openpencil-web:v0.8.1` | Rust web host, wasm bundle, CanvasKit assets | 웹 UI는 내장 agent profiles만 노출합니다. Claude/Codex/OpenCode/Copilot/Gemini CLI 도구는 Docker 이미지에 포함되지 않습니다. **실행:** ```bash -docker run -d -p 3100:3100 ghcr.io/zseven-w/openpencil-web:v0.8.0 +docker run -d -p 3100:3100 ghcr.io/zseven-w/openpencil-web:v0.8.1 ``` 그런 다음 `http://localhost:3100/`을 여세요. @@ -308,7 +308,7 @@ OpenPencil은 처음부터 **Rust**로 다시 작성되었습니다 ([#129](http - **네이티브 접근성** — 브라우저의 접근성 트리에 의존하는 대신 macOS, Windows, Linux에서는 AccessKit을, 웹에서는 DOM 미러를 사용합니다. - **타입 검사된 단일 워크스페이스** — MCP 호스트, CLI, AI 제공자, 코드 생성, Figma 가져오기, Git 통합이 모두 단일 Rust 워크스페이스 안에 있으며, CI에서 `cargo-deny`로 공급망을 검증합니다. -> **상태:** TypeScript 에디터는 `v0.7.5`에서 은퇴하여 이제 git 히스토리에만 남아 있으며, 이 저장소는 Rust 워크스페이스입니다. `v0.8.0` Rust 릴리스는 활발히 개발 중입니다 (아래 로드맵 참고). +> **상태:** TypeScript 에디터는 `v0.7.5`에서 은퇴하여 이제 git 히스토리에만 남아 있으며, 이 저장소는 Rust 워크스페이스입니다. `v0.8.1` Rust 릴리스는 활발히 개발 중입니다 (아래 로드맵 참고). ## 프로젝트 구조 diff --git a/README.md b/README.md index ccdea9419..6875f9bf0 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,39 @@ Three submodules live under `vendor/`, all public and fetched over HTTPS (no SSH bash scripts/start-web-rust.sh ``` +Browser-entered built-in provider, image-generation, and Openverse credentials +are kept in same-origin `localStorage` +(`openpencil-rust-web-credentials`) by default, +separate from ordinary web preferences. A credential needed by an AI request is +sent with that request, but the daemon does not write the browser snapshot to +`settings.json`. + +Private deployments can opt into server persistence with +`OPENPENCIL_PERSIST_WEB_CREDENTIALS_SERVER=true`. This setting is +deployment-wide; each accepted browser snapshot replaces the shared +browser-owned copy. Enable it only for a trusted, access-controlled deployment +with one active administrator profile, behind HTTPS. Turning the setting off +removes previously persisted browser-owned credentials at the next startup +while preserving operator-owned configuration. As with all `localStorage`, +same-origin scripts can read these values, so the web origin must be protected +from untrusted scripts and XSS. Before downgrading to a release that predates +this switch, turn it off and start the current release once to scrub the +browser-owned copy. + +The web host supports built-in API-key agents only. CLI and ACP agents remain +native-only: they are not shown in the web model picker, stored in browser +credential snapshots, synchronized to the server, or exposed through web +connect routes. Browser requests to credential and AI endpoints allow +loopback/localhost origins by default. A public reverse proxy must separately set +`OPENPENCIL_WEB_ALLOWED_ORIGINS` to a comma-separated list of exact origins, +for example `https://demo.openpencil.dev`. + +Server persistence does not permit browser-controlled requests to loopback, +private, link-local, metadata, or reserved provider endpoints. A trusted private +deployment that intentionally uses an internal provider must separately set +`OPENPENCIL_WEB_AI_ENDPOINT_ALLOWLIST` to a comma-separated list of exact URL +origins, for example `http://127.0.0.1:11434,https://llm.internal.example`. + Or run as a desktop app: ```bash @@ -208,14 +241,14 @@ Tagged Rust releases publish a single web-host image. The retired TypeScript ima | Image | Includes | | --- | --- | -| `ghcr.io/zseven-w/openpencil-web:v0.8.0` | Rust web host, wasm bundle, and CanvasKit assets | +| `ghcr.io/zseven-w/openpencil-web:v0.8.1` | Rust web host, wasm bundle, and CanvasKit assets | The web UI exposes built-in agent profiles only; Claude/Codex/OpenCode/Copilot/Gemini CLI tooling is not bundled into Docker images. **Run:** ```bash -docker run -d -p 3100:3100 ghcr.io/zseven-w/openpencil-web:v0.8.0 +docker run -d -p 3100:3100 ghcr.io/zseven-w/openpencil-web:v0.8.1 ``` Then open `http://localhost:3100/`. @@ -413,7 +446,7 @@ OpenPencil was rewritten from the ground up in **Rust** ([#129](https://github.c - **Native accessibility** — AccessKit on macOS, Windows, and Linux, plus a DOM mirror on web, instead of leaning on a browser's a11y tree. - **One type-checked workspace** — the MCP host, CLI, AI providers, code generation, Figma import, and Git integration all live in a single Rust workspace, with `cargo-deny` supply-chain gating in CI. -> **Status:** the TypeScript editor was retired at `v0.7.5` and lives only in git history; this repository is the Rust workspace. The `v0.8.0` Rust release is under active development (see the Roadmap below). +> **Status:** the TypeScript editor was retired at `v0.7.5` and lives only in git history; this repository is the Rust workspace. The Rust `v0.8` release line is under active development (see the Roadmap below). ## Project Structure diff --git a/README.pt.md b/README.pt.md index f82ad7e4d..93dca4393 100644 --- a/README.pt.md +++ b/README.pt.md @@ -123,14 +123,14 @@ Releases Rust com tag publicam uma única imagem de web host. As imagens TypeScr | Imagem | Inclui | | --- | --- | -| `ghcr.io/zseven-w/openpencil-web:v0.8.0` | Rust web host, wasm bundle e assets do CanvasKit | +| `ghcr.io/zseven-w/openpencil-web:v0.8.1` | Rust web host, wasm bundle e assets do CanvasKit | A UI web expõe apenas perfis de agente integrados; as ferramentas Claude/Codex/OpenCode/Copilot/Gemini CLI não são incluídas nas imagens Docker. **Executar:** ```bash -docker run -d -p 3100:3100 ghcr.io/zseven-w/openpencil-web:v0.8.0 +docker run -d -p 3100:3100 ghcr.io/zseven-w/openpencil-web:v0.8.1 ``` Depois abra `http://localhost:3100/`. @@ -308,7 +308,7 @@ O OpenPencil foi reescrito do zero em **Rust** ([#129](https://github.com/ZSeven - **Acessibilidade nativa** — AccessKit no macOS, Windows e Linux, mais um espelho DOM na web, em vez de depender da árvore de acessibilidade de um navegador. - **Um workspace com verificação de tipos** — o host MCP, CLI, provedores de AI, geração de código, importação do Figma e integração com Git vivem em um único workspace Rust, com o `cargo-deny` fazendo controle da cadeia de suprimentos na CI. -> **Status:** o editor TypeScript foi descontinuado na `v0.7.5` e existe apenas no histórico do Git; este repositório é o workspace Rust. O lançamento Rust `v0.8.0` está em desenvolvimento ativo (consulte o Roadmap abaixo). +> **Status:** o editor TypeScript foi descontinuado na `v0.7.5` e existe apenas no histórico do Git; este repositório é o workspace Rust. O lançamento Rust `v0.8.1` está em desenvolvimento ativo (consulte o Roadmap abaixo). ## Estrutura do Projeto diff --git a/README.ru.md b/README.ru.md index 224e4fcd1..7e3766fb2 100644 --- a/README.ru.md +++ b/README.ru.md @@ -123,14 +123,14 @@ cargo run -p op-host-desktop | Образ | Содержит | | --- | --- | -| `ghcr.io/zseven-w/openpencil-web:v0.8.0` | Rust web host, wasm bundle и ресурсы CanvasKit | +| `ghcr.io/zseven-w/openpencil-web:v0.8.1` | Rust web host, wasm bundle и ресурсы CanvasKit | Веб-интерфейс показывает только встроенные профили агентов; инструменты Claude/Codex/OpenCode/Copilot/Gemini CLI не входят в Docker-образы. **Запуск:** ```bash -docker run -d -p 3100:3100 ghcr.io/zseven-w/openpencil-web:v0.8.0 +docker run -d -p 3100:3100 ghcr.io/zseven-w/openpencil-web:v0.8.1 ``` Затем откройте `http://localhost:3100/`. @@ -308,7 +308,7 @@ OpenPencil переписан с нуля на **Rust** ([#129](https://github.c - **Нативная доступность** — AccessKit на macOS, Windows и Linux, плюс DOM-зеркало на вебе, вместо опоры на дерево доступности браузера. - **Одно типизированное пространство** — хост MCP, CLI, AI-провайдеры, генерация кода, импорт Figma и интеграция с Git — всё в одном Rust-пространстве с контролем цепочки поставок через `cargo-deny` в CI. -> **Статус:** редактор на TypeScript был выведен из эксплуатации в `v0.7.5` и сохранился только в истории git; этот репозиторий — это Rust-пространство. Релиз `v0.8.0` на Rust находится в активной разработке (см. [Дорожная карта](#дорожная-карта) ниже). +> **Статус:** редактор на TypeScript был выведен из эксплуатации в `v0.7.5` и сохранился только в истории git; этот репозиторий — это Rust-пространство. Релиз `v0.8.1` на Rust находится в активной разработке (см. [Дорожная карта](#дорожная-карта) ниже). ## Структура проекта diff --git a/README.th.md b/README.th.md index 995da91d1..bba68ed80 100644 --- a/README.th.md +++ b/README.th.md @@ -123,14 +123,14 @@ Rust release ที่มี tag จะเผยแพร่ image แบบ web | Image | รวม | | --- | --- | -| `ghcr.io/zseven-w/openpencil-web:v0.8.0` | Rust web host, wasm bundle และ CanvasKit assets | +| `ghcr.io/zseven-w/openpencil-web:v0.8.1` | Rust web host, wasm bundle และ CanvasKit assets | Web UI แสดงเฉพาะ built-in agent profiles เท่านั้น เครื่องมือ Claude/Codex/OpenCode/Copilot/Gemini CLI จะไม่ถูกบันเดิลใน Docker images **รัน:** ```bash -docker run -d -p 3100:3100 ghcr.io/zseven-w/openpencil-web:v0.8.0 +docker run -d -p 3100:3100 ghcr.io/zseven-w/openpencil-web:v0.8.1 ``` จากนั้นเปิด `http://localhost:3100/` @@ -308,7 +308,7 @@ OpenPencil ถูกเขียนใหม่ตั้งแต่ต้นด - **Accessibility แบบ native** — AccessKit บน macOS, Windows และ Linux พร้อม DOM mirror บนเว็บ แทนที่จะพึ่งพา a11y tree ของ browser - **Workspace ที่ตรวจสอบ type เดียว** — MCP host, CLI, AI providers, code generation, Figma import และ Git integration ทั้งหมดอยู่ใน Rust workspace เดียว พร้อม `cargo-deny` ตรวจสอบ supply-chain ใน CI -> **สถานะ:** editor แบบ TypeScript ถูกเลิกใช้ที่ `v0.7.5` และเหลืออยู่เพียงใน git history เท่านั้น รีโปนี้คือ Rust workspace รุ่น Rust `v0.8.0` อยู่ระหว่างการพัฒนาอย่างต่อเนื่อง (ดู Roadmap ด้านล่าง) +> **สถานะ:** editor แบบ TypeScript ถูกเลิกใช้ที่ `v0.7.5` และเหลืออยู่เพียงใน git history เท่านั้น รีโปนี้คือ Rust workspace รุ่น Rust `v0.8.1` อยู่ระหว่างการพัฒนาอย่างต่อเนื่อง (ดู Roadmap ด้านล่าง) ## โครงสร้างโปรเจกต์ diff --git a/README.tr.md b/README.tr.md index 49a8bc063..becc2382a 100644 --- a/README.tr.md +++ b/README.tr.md @@ -123,14 +123,14 @@ Etiketli Rust sürümleri tek bir web host görüntüsü yayımlar. AI CLI'ları | Görüntü | İçerik | | --- | --- | -| `ghcr.io/zseven-w/openpencil-web:v0.8.0` | Rust web host, wasm bundle ve CanvasKit varlıkları | +| `ghcr.io/zseven-w/openpencil-web:v0.8.1` | Rust web host, wasm bundle ve CanvasKit varlıkları | Web UI yalnızca yerleşik agent profillerini gösterir; Claude/Codex/OpenCode/Copilot/Gemini CLI araçları Docker görüntülerine dahil değildir. **Çalıştır:** ```bash -docker run -d -p 3100:3100 ghcr.io/zseven-w/openpencil-web:v0.8.0 +docker run -d -p 3100:3100 ghcr.io/zseven-w/openpencil-web:v0.8.1 ``` Ardından `http://localhost:3100/` adresini açın. @@ -308,7 +308,7 @@ OpenPencil, sıfırdan **Rust** ile yeniden yazıldı ([#129](https://github.com - **Yerel erişilebilirlik** — tarayıcının a11y ağacına güvenmek yerine macOS, Windows ve Linux'ta AccessKit; web'de ise bir DOM aynası. - **Tek tip denetimli çalışma alanı** — MCP sunucusu, CLI, AI sağlayıcıları, kod üretimi, Figma içe aktarma ve Git entegrasyonu; CI'da `cargo-deny` tedarik zinciri denetimi ile tek bir Rust çalışma alanında bulunur. -> **Durum:** TypeScript editörü `v0.7.5` sürümünde kullanımdan kaldırıldı ve yalnızca git geçmişinde bulunuyor; bu depo artık Rust çalışma alanıdır. `v0.8.0` Rust sürümü etkin geliştirme aşamasındadır (aşağıdaki Yol Haritası'na bakın). +> **Durum:** TypeScript editörü `v0.7.5` sürümünde kullanımdan kaldırıldı ve yalnızca git geçmişinde bulunuyor; bu depo artık Rust çalışma alanıdır. `v0.8.1` Rust sürümü etkin geliştirme aşamasındadır (aşağıdaki Yol Haritası'na bakın). ## Proje Yapısı diff --git a/README.vi.md b/README.vi.md index 4fd78d790..d72ae98a9 100644 --- a/README.vi.md +++ b/README.vi.md @@ -123,14 +123,14 @@ Các bản phát hành Rust có tag chỉ phát hành một image web host. Các | Image | Bao gồm | | --- | --- | -| `ghcr.io/zseven-w/openpencil-web:v0.8.0` | Rust web host, wasm bundle và tài nguyên CanvasKit | +| `ghcr.io/zseven-w/openpencil-web:v0.8.1` | Rust web host, wasm bundle và tài nguyên CanvasKit | Web UI chỉ hiển thị các built-in agent profiles; công cụ Claude/Codex/OpenCode/Copilot/Gemini CLI không được đóng gói trong Docker images. **Chạy:** ```bash -docker run -d -p 3100:3100 ghcr.io/zseven-w/openpencil-web:v0.8.0 +docker run -d -p 3100:3100 ghcr.io/zseven-w/openpencil-web:v0.8.1 ``` Sau đó mở `http://localhost:3100/`. @@ -308,7 +308,7 @@ OpenPencil đã được viết lại từ đầu bằng **Rust** ([#129](https: - **Trợ năng gốc** — AccessKit trên macOS, Windows và Linux, cộng với DOM mirror trên web, thay vì dựa vào cây a11y của browser. - **Một workspace có kiểm tra kiểu** — MCP host, CLI, nhà cung cấp AI, tạo mã, nhập Figma và tích hợp Git đều nằm trong một Rust workspace duy nhất, với `cargo-deny` kiểm soát chuỗi cung ứng trong CI. -> **Trạng thái:** editor TypeScript đã bị khai tử tại `v0.7.5` và chỉ còn tồn tại trong lịch sử git; repo này chính là Rust workspace. Bản phát hành Rust `v0.8.0` đang được phát triển tích cực (xem Lộ trình bên dưới). +> **Trạng thái:** editor TypeScript đã bị khai tử tại `v0.7.5` và chỉ còn tồn tại trong lịch sử git; repo này chính là Rust workspace. Bản phát hành Rust `v0.8.1` đang được phát triển tích cực (xem Lộ trình bên dưới). ## Cấu trúc dự án diff --git a/README.zh-TW.md b/README.zh-TW.md index 1003006f5..87cacb745 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -123,14 +123,14 @@ cargo run -p op-host-desktop | 映像檔 | 包含 | | --- | --- | -| `ghcr.io/zseven-w/openpencil-web:v0.8.0` | Rust web host、wasm bundle 和 CanvasKit 資源 | +| `ghcr.io/zseven-w/openpencil-web:v0.8.1` | Rust web host、wasm bundle 和 CanvasKit 資源 | Web UI 僅公開內建 agent profiles;Docker 映像檔不再內建 Claude/Codex/OpenCode/Copilot/Gemini CLI 工具。 **執行:** ```bash -docker run -d -p 3100:3100 ghcr.io/zseven-w/openpencil-web:v0.8.0 +docker run -d -p 3100:3100 ghcr.io/zseven-w/openpencil-web:v0.8.1 ``` 然後開啟 `http://localhost:3100/`。 @@ -308,7 +308,7 @@ OpenPencil 已以 **Rust** 從頭重寫完成([#129](https://github.com/ZSeven - **原生無障礙支援** — 在 macOS、Windows 和 Linux 使用 AccessKit,在 Web 上使用 DOM 鏡像,而非依賴瀏覽器的 a11y 樹狀結構。 - **單一型別檢查 workspace** — MCP 宿主、CLI、AI 供應商、程式碼生成、Figma 匯入和 Git 整合全部位於單一 Rust workspace,並由 CI 中的 `cargo-deny` 進行供應鏈管控。 -> **狀態:** TypeScript 版編輯器已在 `v0.7.5` 退役,僅存於 Git 歷史紀錄中;本存放庫即為 Rust workspace。`v0.8.0` 的 Rust 版本正在積極開發中(請見下方路線圖)。 +> **狀態:** TypeScript 版編輯器已在 `v0.7.5` 退役,僅存於 Git 歷史紀錄中;本存放庫即為 Rust workspace。`v0.8.1` 的 Rust 版本正在積極開發中(請見下方路線圖)。 ## 專案結構 diff --git a/README.zh.md b/README.zh.md index e5aabbab9..418252cee 100644 --- a/README.zh.md +++ b/README.zh.md @@ -123,14 +123,14 @@ cargo run -p op-host-desktop | 镜像 | 包含 | | --- | --- | -| `ghcr.io/zseven-w/openpencil-web:v0.8.0` | Rust web host、wasm bundle 和 CanvasKit 资源 | +| `ghcr.io/zseven-w/openpencil-web:v0.8.1` | Rust web host、wasm bundle 和 CanvasKit 资源 | Web UI 只暴露内置 agent profiles;Docker 镜像不再内置 Claude/Codex/OpenCode/Copilot/Gemini CLI 工具。 **运行:** ```bash -docker run -d -p 3100:3100 ghcr.io/zseven-w/openpencil-web:v0.8.0 +docker run -d -p 3100:3100 ghcr.io/zseven-w/openpencil-web:v0.8.1 ``` 然后打开 `http://localhost:3100/`。 @@ -308,7 +308,7 @@ OpenPencil 已从头用 **Rust** 完成重写([#129](https://github.com/ZSeven - **原生无障碍** — macOS、Windows 和 Linux 上通过 AccessKit 实现,Web 端通过 DOM 镜像实现,而非依赖浏览器自身的无障碍树。 - **单一类型检查工作区** — MCP 宿主、CLI、AI 提供商、代码生成、Figma 导入和 Git 集成全部位于同一个 Rust workspace,并在 CI 中通过 `cargo-deny` 进行供应链管控。 -> **状态:** TypeScript 版编辑器已在 `v0.7.5` 退役,仅存在于 Git 历史中;本仓库即为 Rust workspace。`v0.8.0` 版本的 Rust 发布正在积极开发中(参见下方路线图)。 +> **状态:** TypeScript 版编辑器已在 `v0.7.5` 退役,仅存在于 Git 历史中;本仓库即为 Rust workspace。`v0.8.1` 版本的 Rust 发布正在积极开发中(参见下方路线图)。 ## 项目结构 diff --git a/RELEASE_NOTES/v0.8.1.md b/RELEASE_NOTES/v0.8.1.md new file mode 100644 index 000000000..d268a7145 --- /dev/null +++ b/RELEASE_NOTES/v0.8.1.md @@ -0,0 +1,43 @@ +# OpenPencil v0.8.1 + +`v0.8.1` is a pre-release update for the Rust OpenPencil stack. It focuses on a more reliable AI-assisted design loop, broader export workflows, canvas polish, and safer credential handling for web deployments. + +## Highlights + +- Web deployments now default to browser-only AI credential persistence. API keys entered in the browser are stored in that origin's `localStorage` and are sent to the server only with the request that needs them. +- Private deployments can opt back into server-side credential persistence with `OPENPENCIL_PERSIST_WEB_CREDENTIALS_SERVER=true`. +- The design agent is more resilient with weaker models: design-system bootstrapping, bounded repair, geometry-aware image placement, and clearer canvas feedback improve generated layouts without unbounded retries. +- Design-loop narration and tool activity are interleaved in the chat transcript, with improved markdown rendering and agent identity. +- CLI and MCP export flows now cover pages, nodes, and live selection; headless MCP startup validates documents before serving them. +- Canvas and editor updates include Pencil-style cursors, generation placeholders, clipboard image paste, fill-layer reordering, text-raster scaling fixes, and component-instance child editing. + +## Web Credential Persistence + +The default web policy is designed for public demo deployments: + +- Built-in provider API keys and supported image-service credentials stay in browser `localStorage`. +- General non-secret web preferences remain in their existing browser settings snapshot. +- Credential-bearing requests are accepted only from the same origin, and proxy targets are validated before use. +- Browser-only mode removes browser-owned credentials left in the server settings file by an earlier configuration. +- Upstream error bodies are not reflected to browser clients, reducing the chance of leaking provider details. + +Operators of trusted private deployments can set: + +```sh +OPENPENCIL_PERSIST_WEB_CREDENTIALS_SERVER=true +``` + +This restores server-side persistence for supported web-entered credentials while keeping the same request validation protections. The web host exposes built-in API-key agents only; CLI and ACP agents remain native-only and their connect routes are unavailable on web. Internal provider origins remain blocked unless they are explicitly listed in the separate comma-delimited `OPENPENCIL_WEB_AI_ENDPOINT_ALLOWLIST` setting; enabling persistence never enables unrestricted outbound requests. Public reverse proxies must also list their exact browser origins in `OPENPENCIL_WEB_ALLOWED_ORIGINS`; loopback and localhost origins remain allowed by default. + +## Release Artifacts + +- Native desktop installers and standalone `op` CLI archives for macOS, Windows, and Linux. +- `ghcr.io/zseven-w/openpencil-web:v0.8.1` for the Rust web host. +- npm packages and attached tarballs for `@zseven-w/op-web-sdk`, `@zseven-w/op-web-sdk-react`, and `@zseven-w/op-web-sdk-vue`. +- Updated Homebrew and Scoop manifests after the GitHub pre-release is published. + +## Compatibility + +- The `.op` document format remains compatible with the `0.8` release line; document-schema version strings are unchanged. +- The public web SDK remains a read-only viewer surface. +- The retired TypeScript application workspace is not part of this release. diff --git a/crates/op-editor-core/src/agent_settings.rs b/crates/op-editor-core/src/agent_settings.rs index ddc12cf75..dfe2080d1 100644 --- a/crates/op-editor-core/src/agent_settings.rs +++ b/crates/op-editor-core/src/agent_settings.rs @@ -443,6 +443,9 @@ pub struct AgentSettings { pub hover_image_gen_add_button: bool, pub openverse_client_id: String, pub openverse_client_secret: String, + /// Provenance marker for Openverse credentials copied from a web + /// deployment. `None` means the singleton is operator-managed. + pub openverse_credential_owner: Option, pub image_gen_profiles: Vec, pub active_image_gen_profile_id: Option, pub image_gen_provider_menu_open: Option, @@ -506,6 +509,7 @@ impl Default for AgentSettings { hover_image_gen_add_button: false, openverse_client_id: String::new(), openverse_client_secret: String::new(), + openverse_credential_owner: None, image_gen_profiles: Vec::new(), active_image_gen_profile_id: None, image_gen_provider_menu_open: None, @@ -529,6 +533,56 @@ impl Default for AgentSettings { } impl AgentSettings { + /// Turn a browser-snapshot built-in into a daemon/operator-owned entry + /// before native settings mutate it. Browser snapshots identify ownership + /// through the scoped id, so changing the id is the ownership transfer. + pub fn take_over_browser_builtin_agent(&mut self, index: usize) -> bool { + let Some(old_id) = self + .builtin_agents + .get(index) + .map(|agent| agent.id.clone()) + .filter(|id| id.starts_with("web-credential:builtin:")) + else { + return false; + }; + let next = next_free_numeric_id( + self.next_builtin_agent_id, + "builtin-", + self.builtin_agents.iter().map(|agent| agent.id.as_str()), + ); + self.builtin_agents[index].id = format!("builtin-{next}"); + self.next_builtin_agent_id = next.saturating_add(1); + debug_assert_ne!(self.builtin_agents[index].id, old_id); + true + } + + /// Operator ownership transfer for a browser-snapshot image profile. Keep + /// the active-profile pointer aligned with the newly allocated local id. + pub fn take_over_browser_image_profile(&mut self, index: usize) -> bool { + let Some(old_id) = self + .image_gen_profiles + .get(index) + .map(|profile| profile.id.clone()) + .filter(|id| id.starts_with("web-credential:image:")) + else { + return false; + }; + let next = next_free_numeric_id( + self.next_image_gen_profile_id, + "igp-", + self.image_gen_profiles + .iter() + .map(|profile| profile.id.as_str()), + ); + let new_id = format!("igp-{next}"); + self.image_gen_profiles[index].id = new_id.clone(); + self.next_image_gen_profile_id = next.saturating_add(1); + if self.active_image_gen_profile_id.as_deref() == Some(old_id.as_str()) { + self.active_image_gen_profile_id = Some(new_id); + } + true + } + pub fn add_builtin_agent(&mut self) -> String { if let Some(preset) = BUILTIN_AGENT_PRESETS.iter().find(|preset| { !self @@ -792,6 +846,25 @@ impl AgentSettings { } } +fn next_free_numeric_id<'a>( + requested: u64, + prefix: &str, + ids: impl Iterator, +) -> u64 { + let used: std::collections::HashSet = ids + .filter_map(|id| id.strip_prefix(prefix)?.parse().ok()) + .collect(); + let mut next = requested.max(1); + while used.contains(&next) { + let advanced = next.saturating_add(1); + if advanced == next { + break; + } + next = advanced; + } + next +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AgentSettingsDrag { Reserved, diff --git a/crates/op-host-desktop/Cargo.toml b/crates/op-host-desktop/Cargo.toml index f27530feb..a487b78ce 100644 --- a/crates/op-host-desktop/Cargo.toml +++ b/crates/op-host-desktop/Cargo.toml @@ -62,7 +62,7 @@ role = "Editor" # Phase 7.3 reorg: openpencil-shell-core dissolved — widget facade / # render-backend / layout scene paths resolve through op-editor-ui. op-editor-ui = { path = "../op-editor-ui" } -op-host-native = { path = "../op-host-native", version = "0.8.0", features = ["gl-host"] } +op-host-native = { path = "../op-host-native", version = "0.8.1", features = ["gl-host"] } # Phase 2: the headless daemon crate. The `--serve-web` closure migrates here # over Phases 2-5; op-host-desktop's serve-web dispatch + GUI residuals call into # it via `op_host_services::*`. diff --git a/crates/op-host-native/src/widget_host/agent_settings_tests.rs b/crates/op-host-native/src/widget_host/agent_settings_tests.rs index 1484ac1ce..a1789c3a5 100644 --- a/crates/op-host-native/src/widget_host/agent_settings_tests.rs +++ b/crates/op-host-native/src/widget_host/agent_settings_tests.rs @@ -90,6 +90,36 @@ fn builtin_agent_api_key_focus_accepts_text_and_rebuilds_models() { ); } +#[test] +fn editing_browser_owned_builtin_agent_transfers_it_to_operator_ownership() { + let mut host = WidgetHostNative::new(); + let settings = &mut host.editor_state_mut().editor_ui.agent_settings; + settings.add_builtin_agent_with_defaults("Browser", "sk-browser", "browser-model"); + settings.builtin_agents[0].id = "web-credential:builtin:builtin-web-1".into(); + settings.next_builtin_agent_id = 7; + settings.focus = Some(SettingsFocus::BuiltinAgent { + index: 0, + field: BuiltinAgentField::ApiKey, + }); + host.editor_state_mut() + .editor_ui + .settings_input + .set_text("sk-operator"); + + assert!(host.apply_send()); + + let settings = &host.editor_state().editor_ui.agent_settings; + assert_eq!(settings.builtin_agents[0].id, "builtin-7"); + assert_eq!(settings.builtin_agents[0].api_key, "sk-operator"); + assert_eq!(settings.next_builtin_agent_id, 8); + assert!(host + .editor_state() + .chat + .available_models + .iter() + .any(|model| model.builtin_provider_id.as_deref() == Some("builtin-7"))); +} + #[test] fn builtin_agent_compact_switch_toggles_enabled_and_models() { let mut host = WidgetHostNative::new(); @@ -790,6 +820,35 @@ fn image_generation_profile_focus_accepts_text_and_commits() { .is_empty()); } +#[test] +fn editing_browser_owned_image_profile_transfers_it_to_operator_ownership() { + let mut host = WidgetHostNative::new(); + let settings = &mut host.editor_state_mut().editor_ui.agent_settings; + settings.add_image_gen_profile(); + settings.image_gen_profiles[0].id = "web-credential:image:igp-web-1".into(); + settings.active_image_gen_profile_id = Some("web-credential:image:igp-web-1".into()); + settings.next_image_gen_profile_id = 4; + settings.focus = Some(SettingsFocus::ImageGenProfile { + index: 0, + field: ImageGenField::Name, + }); + host.editor_state_mut() + .editor_ui + .settings_input + .set_text("Operator Images"); + + assert!(host.apply_send()); + + let settings = &host.editor_state().editor_ui.agent_settings; + assert_eq!(settings.image_gen_profiles[0].id, "igp-4"); + assert_eq!(settings.image_gen_profiles[0].name, "Operator Images"); + assert_eq!( + settings.active_image_gen_profile_id.as_deref(), + Some("igp-4") + ); + assert_eq!(settings.next_image_gen_profile_id, 5); +} + #[test] fn image_generation_add_press_sets_and_release_clears_agent_settings_button() { let mut host = WidgetHostNative::new(); diff --git a/crates/op-host-native/src/widget_host/press_helpers.rs b/crates/op-host-native/src/widget_host/press_helpers.rs index 786491c5d..84eca2494 100644 --- a/crates/op-host-native/src/widget_host/press_helpers.rs +++ b/crates/op-host-native/src/widget_host/press_helpers.rs @@ -369,6 +369,10 @@ impl WidgetHostNative { } AgentSettingsHit::ToggleBuiltinAgentKind(index) => { self.commit_settings_focus_if_any(); + self.editor_state + .editor_ui + .agent_settings + .take_over_browser_builtin_agent(index); if let Some(agent) = self .editor_state .editor_ui @@ -401,6 +405,10 @@ impl WidgetHostNative { self.commit_settings_focus_if_any(); match index { Some(index) => { + self.editor_state + .editor_ui + .agent_settings + .take_over_browser_builtin_agent(index); self.editor_state .editor_ui .agent_settings @@ -429,6 +437,10 @@ impl WidgetHostNative { } AgentSettingsHit::ToggleBuiltinAgentEnabled(index) => { self.commit_settings_focus_if_any(); + self.editor_state + .editor_ui + .agent_settings + .take_over_browser_builtin_agent(index); if let Some(agent) = self .editor_state .editor_ui diff --git a/crates/op-host-native/src/widget_host/release_feedback.rs b/crates/op-host-native/src/widget_host/release_feedback.rs index f6655730c..0a6c9c17a 100644 --- a/crates/op-host-native/src/widget_host/release_feedback.rs +++ b/crates/op-host-native/src/widget_host/release_feedback.rs @@ -53,6 +53,7 @@ impl WidgetHostNative { })) => { { let settings = &mut self.editor_state.editor_ui.agent_settings; + settings.take_over_browser_image_profile(index); if let Some(profile) = settings.image_gen_profiles.get_mut(index) { if profile.provider != provider { profile.provider = provider; diff --git a/crates/op-host-native/src/widget_host/settings_dispatch.rs b/crates/op-host-native/src/widget_host/settings_dispatch.rs index 2a5ae397e..b257cea9e 100644 --- a/crates/op-host-native/src/widget_host/settings_dispatch.rs +++ b/crates/op-host-native/src/widget_host/settings_dispatch.rs @@ -25,22 +25,26 @@ impl WidgetHostNative { .editor_ui .agent_settings .openverse_client_id = draft.trim().to_string(); + self.editor_state + .editor_ui + .agent_settings + .openverse_credential_owner = None; } op_editor_core::agent_settings::ImageSearchField::ClientSecret => { self.editor_state .editor_ui .agent_settings .openverse_client_secret = draft.trim().to_string(); + self.editor_state + .editor_ui + .agent_settings + .openverse_credential_owner = None; } }, SettingsFocus::BuiltinAgent { index, field } => { - if let Some(agent) = self - .editor_state - .editor_ui - .agent_settings - .builtin_agents - .get_mut(index) - { + let settings = &mut self.editor_state.editor_ui.agent_settings; + settings.take_over_browser_builtin_agent(index); + if let Some(agent) = settings.builtin_agents.get_mut(index) { match field { BuiltinAgentField::DisplayName => { if !draft.trim().is_empty() { @@ -99,13 +103,9 @@ impl WidgetHostNative { } } SettingsFocus::ImageGenProfile { index, field } => { - if let Some(profile) = self - .editor_state - .editor_ui - .agent_settings - .image_gen_profiles - .get_mut(index) - { + let settings = &mut self.editor_state.editor_ui.agent_settings; + settings.take_over_browser_image_profile(index); + if let Some(profile) = settings.image_gen_profiles.get_mut(index) { match field { ImageGenField::Name => { profile.name = draft.trim().to_string(); diff --git a/crates/op-host-services/src/ai_proxy.rs b/crates/op-host-services/src/ai_proxy.rs index d7ba29858..7b4a78de5 100644 --- a/crates/op-host-services/src/ai_proxy.rs +++ b/crates/op-host-services/src/ai_proxy.rs @@ -1,9 +1,9 @@ //! Backend AI proxy for the WASM web bundle. //! -//! The browser shell (`op-host-web`) can't bundle the skill corpus or -//! hold provider API keys, so it POSTs a model request here and the -//! desktop daemon expands skill NAMES into a system prompt (via -//! `op_ai_skills::compose_system_prompt`, server-side) and streams the +//! The browser shell (`op-host-web`) can't bundle the skill corpus or run +//! native providers, so it POSTs a model request here (with an optional +//! request-scoped browser credential) and the daemon expands skill NAMES via +//! `op_ai_skills::compose_system_prompt`, then streams the //! provider's `ChatDelta`s back as Server-Sent Events. The framing and //! skill-expansion live here (pure + tested); `web_canvas_server.rs` //! only routes `/api/ai/stream` (POST → SSE) and `/api/ai/models` @@ -11,17 +11,11 @@ use std::io::Write; -use op_ai::chat_provider::{ - ChatDelta, ChatProvider, ChatRequest, CliName, EffortLevel, ThinkingMode, -}; -use op_editor_core::{AgentProvider, EditorState, ModelEntry}; +use op_ai::chat_provider::{ChatDelta, ChatProvider, ChatRequest, EffortLevel, ThinkingMode}; +use op_editor_core::{BuiltinAgentConfig, EditorState}; use serde_json::{json, Value}; use crate::chat_builtin_http::ConfiguredBuiltinProvider; -use crate::chat_claude::ClaudeCodeProvider; -use crate::chat_copilot::CopilotProvider; -use crate::chat_http_server::OpenCodeProvider; -use crate::chat_subprocess::SubprocessProvider; /// Parsed `POST /api/ai/stream` body. The web bundle sends skill NAMES /// (not the corpus) plus the per-turn knobs; the proxy expands the @@ -33,6 +27,7 @@ pub struct AiStreamRequest { pub max_output_tokens: u32, pub thinking: ThinkingMode, pub effort: EffortLevel, + pub transient_builtin: Option, } /// Parse a `/api/ai/stream` JSON body into an [`AiStreamRequest`]. @@ -79,6 +74,10 @@ pub fn parse_ai_stream_body(body: &str) -> Option { .and_then(Value::as_str) .map(parse_effort) .unwrap_or_default(); + let transient_builtin = match obj.get("credential") { + None | Some(Value::Null) => None, + Some(value) => Some(crate::web_credentials::parse_transient_builtin(value)?), + }; Some(AiStreamRequest { model, skills, @@ -86,6 +85,7 @@ pub fn parse_ai_stream_body(body: &str) -> Option { max_output_tokens, thinking, effort, + transient_builtin, }) } @@ -199,50 +199,77 @@ pub fn write_sse_error(out: &mut W, message: &str) -> std::io::Result< } /// Build a `ChatProvider` for the proxy from the editor's agent -/// settings. Returns the first ready built-in (API-key) agent whose -/// model matches `model`, else the first ready built-in agent -/// regardless of model, else `None` (the route then emits an SSE -/// error). The proxy is headless — it has no UI host — so it builds the +/// settings. Returns the permitted ready built-in (API-key) agent whose model +/// matches `model`; only an empty or `default` model selects the first ready +/// built-in. Unknown, CLI, and ACP model ids return `None`. An exact +/// browser-owned model whose endpoint is no longer permitted fails closed. +/// The proxy is headless — it has no UI host — so it builds the /// provider directly from `agent_settings.builtin_agents` rather than /// going through `chat_session::provider_for_selected_model` (which /// takes the live `WidgetHostNative`). -/// -// TODO: full provider parity with chat_session — ACP agents still need the -// live MCP lifecycle. Built-in API-key agents and verified CLI providers are -// supported here because they can be driven from the headless web daemon. pub fn proxy_provider(editor: &EditorState, model: &str) -> Option> { proxy_provider_with_chat_session(editor, model, true) } -/// Build a proxy provider while choosing whether CLI-backed providers should -/// join their resumable chat sessions. Plain `/api/ai/stream` uses chat -/// sessions; standard-mode classification/design calls pass `false` so those -/// internal LLM calls do not pollute the user's chat conversation. +pub fn proxy_provider_for_request( + editor: &EditorState, + request: &AiStreamRequest, + _policy: crate::web_credential_policy::WebCredentialPersistence, +) -> Result>, String> { + let Some(agent) = request.transient_builtin.as_ref() else { + return Ok(proxy_provider(editor, &request.model)); + }; + if agent.model.trim() != request.model.trim() { + return Err("transient credential model does not match the request".into()); + } + crate::web_credentials::validate_web_provider_base_url(&agent.base_url)?; + if !crate::web_credentials::public_demo_transient_endpoint_allowed(agent) { + return Err("custom provider endpoint is not explicitly allowed".into()); + } + Ok(ConfiguredBuiltinProvider::from_builtin_agent(agent) + .map(|provider| Box::new(provider) as Box)) +} + +/// Build the built-in provider selected by a web request. The web daemon does +/// not expose host CLI or ACP providers. pub fn proxy_provider_with_chat_session( editor: &EditorState, model: &str, - chat_session: bool, + _chat_session: bool, ) -> Option> { let agents = &editor.editor_ui.agent_settings.builtin_agents; // Prefer an exact model match so a request for a specific model // reaches the agent configured for it. - if let Some(chosen) = agents - .iter() - .find(|a| a.ready() && a.model.trim() == model.trim()) + let exact_builtin_agents = || { + agents + .iter() + .filter(|agent| agent.ready() && agent.model.trim() == model.trim()) + }; + if let Some(chosen) = + exact_builtin_agents().find(|agent| browser_owned_endpoint_is_allowed(agent)) { let provider = ConfiguredBuiltinProvider::from_builtin_agent(chosen)?; return Some(Box::new(provider)); } - - if let Some(provider) = cli_provider_for_model(editor, model, chat_session) { - return Some(provider); + if exact_builtin_agents().next().is_some() { + return None; } - let chosen = agents.iter().find(|a| a.ready())?; + if !matches!(model.trim(), "" | "default") { + return None; + } + let chosen = agents + .iter() + .find(|agent| agent.ready() && browser_owned_endpoint_is_allowed(agent))?; let provider = ConfiguredBuiltinProvider::from_builtin_agent(chosen)?; Some(Box::new(provider)) } +fn browser_owned_endpoint_is_allowed(agent: &BuiltinAgentConfig) -> bool { + !crate::web_credentials::browser_owns_builtin_agent(agent) + || crate::web_credentials::public_demo_transient_endpoint_allowed(agent) +} + /// JSON body for `GET /api/ai/models` — a JSON array of model ids the /// proxy can serve. Sourced from the editor's configured ready /// built-in agents (the only kind the headless proxy drives); empty @@ -253,88 +280,16 @@ pub fn models_json(editor: &EditorState) -> String { .agent_settings .builtin_agents .iter() - .filter(|a| a.ready()) + .filter(|a| a.ready() && browser_owned_endpoint_is_allowed(a)) .map(|a| a.model.trim()) .filter(|m| !m.is_empty()) .map(str::to_string) .collect(); - models.extend( - editor - .chat - .discovered_models - .iter() - .filter(|m| cli_model_is_available(editor, m)) - .map(|m| m.value.trim()) - .filter(|m| !m.is_empty()) - .map(str::to_string), - ); + let mut seen = std::collections::HashSet::new(); + models.retain(|model| seen.insert(model.clone())); serde_json::to_string(&models).unwrap_or_else(|_| "[]".to_string()) } -fn cli_model_is_available(editor: &EditorState, model: &ModelEntry) -> bool { - model.builtin_provider_id.is_none() - && editor - .editor_ui - .agent_settings - .provider_verified_connected(model.provider) -} - -fn cli_provider_for_model( - editor: &EditorState, - model: &str, - chat_session: bool, -) -> Option> { - let requested = model.trim(); - let entry = if requested.is_empty() || requested == "default" { - editor - .chat - .available_models - .iter() - .find(|m| cli_model_is_available(editor, m)) - .or_else(|| { - editor - .chat - .discovered_models - .iter() - .find(|m| cli_model_is_available(editor, m)) - }) - } else { - editor - .chat - .available_models - .iter() - .find(|m| m.value.trim() == requested && cli_model_is_available(editor, m)) - .or_else(|| { - editor - .chat - .discovered_models - .iter() - .find(|m| m.value.trim() == requested && cli_model_is_available(editor, m)) - }) - }?; - provider_for_cli(entry.provider, chat_session) -} - -fn provider_for_cli(provider: AgentProvider, chat_session: bool) -> Option> { - match provider { - AgentProvider::ClaudeCode => Some(Box::new(if chat_session { - ClaudeCodeProvider::for_chat() - } else { - ClaudeCodeProvider::new() - })), - AgentProvider::CodexCli => SubprocessProvider::for_cli(CliName::Codex) - .map(|p| Box::new(p) as Box), - AgentProvider::OpenCode => Some(Box::new(OpenCodeProvider::new())), - AgentProvider::GithubCopilot => Some(Box::new(if chat_session { - CopilotProvider::for_chat() - } else { - CopilotProvider::new() - })), - AgentProvider::GeminiCli => SubprocessProvider::for_cli(CliName::Gemini) - .map(|p| Box::new(p) as Box), - } -} - #[cfg(test)] mod tests { use super::*; @@ -407,6 +362,91 @@ mod tests { assert!(parse_ai_stream_body("not json").is_none()); } + #[test] + fn request_scoped_builtin_credential_builds_a_provider_without_mutating_settings() { + let body = serde_json::json!({ + "model": "private-model", + "user": "generate", + "credential": { + "id": "builtin-web-1", + "preset": "openai", + "display_name": "Private", + "kind": "openai-compat", + "api_key": "sk-transient", + "model": "private-model", + "base_url": "https://api.openai.com/v1", + "enabled": true + } + }) + .to_string(); + let request = parse_ai_stream_body(&body).expect("request parses"); + let state = EditorState::new(); + + let provider = proxy_provider_for_request( + &state, + &request, + crate::web_credential_policy::WebCredentialPersistence::BrowserOnly, + ) + .expect("public built-in endpoint is allowed"); + + assert!(provider.is_some()); + assert!(state.editor_ui.agent_settings.builtin_agents.is_empty()); + } + + #[test] + fn request_scoped_custom_endpoint_is_rejected_by_browser_only_policy() { + let body = serde_json::json!({ + "model": "private-model", + "user": "generate", + "credential": { + "id": "builtin-web-1", + "preset": "custom", + "display_name": "Private", + "kind": "openai-compat", + "api_key": "sk-transient", + "model": "private-model", + "base_url": "http://127.0.0.1:8080/v1", + "enabled": true + } + }) + .to_string(); + let request = parse_ai_stream_body(&body).expect("request parses"); + + assert!(proxy_provider_for_request( + &EditorState::new(), + &request, + crate::web_credential_policy::WebCredentialPersistence::BrowserOnly, + ) + .is_err()); + } + + #[test] + fn server_persistence_does_not_allow_a_loopback_request_endpoint() { + let body = serde_json::json!({ + "model": "private-model", + "user": "generate", + "credential": { + "id": "builtin-web-1", + "preset": "custom", + "display_name": "Private", + "kind": "openai-compat", + "api_key": "sk-transient", + "model": "private-model", + "base_url": "http://127.0.0.1:8080/v1", + "enabled": true + } + }) + .to_string(); + let request = parse_ai_stream_body(&body).expect("request parses"); + + assert!(proxy_provider_for_request( + &EditorState::new(), + &request, + crate::web_credential_policy::WebCredentialPersistence::Server, + ) + .is_err()); + } + #[test] fn delta_to_sse_frames_text_delta() { assert_eq!( @@ -457,6 +497,7 @@ mod tests { max_output_tokens: 2000, thinking: ThinkingMode::Adaptive, effort: EffortLevel::Low, + transient_builtin: None, }; let provider = EchoProvider { script: vec![ @@ -483,6 +524,7 @@ mod tests { max_output_tokens: 128, thinking: ThinkingMode::Adaptive, effort: EffortLevel::Low, + transient_builtin: None, }; let seen_model = Arc::new(Mutex::new(None)); let provider = CaptureModelProvider { @@ -509,6 +551,7 @@ mod tests { max_output_tokens: 64, thinking: ThinkingMode::Adaptive, effort: EffortLevel::Low, + transient_builtin: None, }; let provider = EchoProvider { script: vec![ @@ -553,16 +596,13 @@ mod tests { } #[test] - fn proxy_provider_falls_back_when_model_unmatched() { - // A request for an unknown model still resolves to the only - // ready agent rather than failing — the proxy serves whatever - // the daemon has configured. + fn proxy_provider_rejects_an_unmatched_model() { let mut editor = EditorState::new(); editor .editor_ui .agent_settings .add_builtin_agent_with_defaults("Built-in Claude", "sk-test", "claude-sonnet-4-5"); - assert!(proxy_provider(&editor, "no-such-model").is_some()); + assert!(proxy_provider(&editor, "no-such-model").is_none()); } #[test] @@ -580,7 +620,7 @@ mod tests { } #[test] - fn models_json_lists_verified_cli_models() { + fn models_json_excludes_verified_cli_models() { let mut editor = EditorState::new(); editor.chat.discovered_models = vec![op_editor_core::ModelEntry::new( op_editor_core::AgentProvider::ClaudeCode, @@ -603,7 +643,7 @@ mod tests { let json = models_json(&editor); let parsed: Value = serde_json::from_str(&json).expect("valid json array"); let arr = parsed.as_array().expect("array"); - assert!(arr.iter().any(|m| m == "claude-sonnet-4-6"), "{json}"); + assert!(arr.is_empty(), "{json}"); } #[test] @@ -622,7 +662,7 @@ mod tests { } #[test] - fn proxy_provider_builds_from_verified_cli_model() { + fn proxy_provider_rejects_verified_cli_model() { let mut editor = EditorState::new(); editor.chat.discovered_models = vec![op_editor_core::ModelEntry::new( op_editor_core::AgentProvider::ClaudeCode, @@ -642,12 +682,11 @@ mod tests { ); editor.rebuild_chat_models(); - let provider = proxy_provider(&editor, "claude-sonnet-4-6").expect("CLI provider builds"); - assert_eq!(provider.provider_label(), "Claude Code"); + assert!(proxy_provider(&editor, "claude-sonnet-4-6").is_none()); } #[test] - fn proxy_provider_default_uses_first_verified_cli_model() { + fn proxy_provider_default_does_not_use_verified_cli_model() { let mut editor = EditorState::new(); editor.chat.discovered_models = vec![op_editor_core::ModelEntry::new( op_editor_core::AgentProvider::ClaudeCode, @@ -667,7 +706,10 @@ mod tests { ); editor.rebuild_chat_models(); - let provider = proxy_provider(&editor, "default").expect("CLI provider builds"); - assert_eq!(provider.provider_label(), "Claude Code"); + assert!(proxy_provider(&editor, "default").is_none()); } } + +#[cfg(test)] +#[path = "ai_proxy_tests.rs"] +mod ai_proxy_tests; diff --git a/crates/op-host-services/src/ai_proxy_tests.rs b/crates/op-host-services/src/ai_proxy_tests.rs new file mode 100644 index 000000000..3e09de410 --- /dev/null +++ b/crates/op-host-services/src/ai_proxy_tests.rs @@ -0,0 +1,163 @@ +use super::*; + +#[test] +fn models_json_deduplicates_models_without_changing_first_seen_order() { + let mut editor = EditorState::new(); + editor + .editor_ui + .agent_settings + .add_builtin_agent_with_defaults("First", "sk-first", "shared-model"); + editor + .editor_ui + .agent_settings + .add_builtin_agent_with_defaults("Duplicate", "sk-duplicate", "shared-model"); + editor + .editor_ui + .agent_settings + .add_builtin_agent_with_defaults("Second", "sk-second", "builtin-model"); + editor.chat.discovered_models = vec![ + op_editor_core::ModelEntry::new( + op_editor_core::AgentProvider::ClaudeCode, + "shared-model", + "Shared", + ), + op_editor_core::ModelEntry::new( + op_editor_core::AgentProvider::ClaudeCode, + "cli-model", + "CLI", + ), + op_editor_core::ModelEntry::new( + op_editor_core::AgentProvider::ClaudeCode, + "cli-model", + "CLI duplicate", + ), + ]; + editor + .editor_ui + .agent_settings + .apply_provider_connect_outcome( + op_editor_core::AgentProvider::ClaudeCode, + op_editor_core::ProviderConnectOutcome { + connected: true, + info: Some("Connected via Claude Code".into()), + ..Default::default() + }, + ); + + assert_eq!( + serde_json::from_str::>(&models_json(&editor)).expect("valid model list"), + vec!["shared-model", "builtin-model"] + ); +} + +#[test] +fn verified_cli_models_are_never_exposed_or_routed_by_the_web_proxy() { + let mut editor = EditorState::new(); + editor + .editor_ui + .agent_settings + .add_builtin_agent_with_defaults("Built-in", "sk-built-in", "built-in-model"); + editor.chat.discovered_models = vec![op_editor_core::ModelEntry::new( + op_editor_core::AgentProvider::ClaudeCode, + "cli-model", + "CLI model", + )]; + editor + .editor_ui + .agent_settings + .apply_provider_connect_outcome( + op_editor_core::AgentProvider::ClaudeCode, + op_editor_core::ProviderConnectOutcome { + connected: true, + info: Some("Connected via CLI".into()), + ..Default::default() + }, + ); + editor.rebuild_chat_models(); + + assert_eq!( + serde_json::from_str::>(&models_json(&editor)).expect("valid model list"), + vec!["built-in-model"] + ); + assert!( + proxy_provider_with_chat_session(&editor, "cli-model", true).is_none(), + "a CLI model must not fall back to an unrelated built-in provider" + ); +} + +#[test] +fn server_persistence_does_not_allow_an_unapproved_public_request_endpoint() { + let body = serde_json::json!({ + "model": "private-model", + "user": "generate", + "credential": { + "id": "builtin-web-1", + "preset": "custom", + "display_name": "Private", + "kind": "openai-compat", + "api_key": "sk-transient", + "model": "private-model", + "base_url": "https://attacker.example/v1", + "enabled": true + } + }) + .to_string(); + let request = parse_ai_stream_body(&body).expect("request parses"); + + let result = proxy_provider_for_request( + &EditorState::new(), + &request, + crate::web_credential_policy::WebCredentialPersistence::Server, + ); + let Err(error) = result else { + panic!("persistence must not authorize an arbitrary public provider endpoint"); + }; + + assert!(error.contains("endpoint"), "unexpected error: {error}"); +} + +#[test] +fn persisted_browser_owned_agent_cannot_use_an_unapproved_public_endpoint() { + let mut editor = EditorState::new(); + editor.editor_ui.agent_settings.add_builtin_agent_config( + "Browser", + "sk-browser", + "private-model", + op_editor_core::BuiltinAgentKind::OpenAiCompat, + "https://attacker.example/v1", + ); + editor.editor_ui.agent_settings.builtin_agents[0].id = + "web-credential:builtin:browser-agent".into(); + + assert!( + proxy_provider(&editor, "private-model").is_none(), + "persisted browser-owned agents still require a preset or explicit allowlist" + ); +} + +#[test] +fn disallowed_exact_browser_model_is_hidden_and_does_not_fall_back() { + let mut editor = EditorState::new(); + editor.editor_ui.agent_settings.add_builtin_agent_config( + "Browser", + "sk-browser", + "private-model", + op_editor_core::BuiltinAgentKind::OpenAiCompat, + "https://attacker.example/v1", + ); + editor.editor_ui.agent_settings.builtin_agents[0].id = + "web-credential:builtin:browser-agent".into(); + editor + .editor_ui + .agent_settings + .add_builtin_agent_with_defaults("Operator", "sk-operator", "operator-model"); + + assert_eq!( + serde_json::from_str::>(&models_json(&editor)).expect("valid model list"), + vec!["operator-model"] + ); + assert!( + proxy_provider(&editor, "private-model").is_none(), + "a disallowed exact browser model must not route to an unrelated provider" + ); +} diff --git a/crates/op-host-services/src/chat_builtin_http.rs b/crates/op-host-services/src/chat_builtin_http.rs index c14a7c19f..3fbadf5c6 100644 --- a/crates/op-host-services/src/chat_builtin_http.rs +++ b/crates/op-host-services/src/chat_builtin_http.rs @@ -428,16 +428,15 @@ pub(crate) async fn send_with_backoff( tokio::time::sleep(delay).await; continue; } - let body = resp.text().await.unwrap_or_default(); if status == reqwest::StatusCode::TOO_MANY_REQUESTS { - // Raw provider JSON reads as gibberish in the chat - // transcript — say what happened and what to do. return Err(format!( - "The model provider is rate-limiting this account (429) and the run could not ride it out after {max_retries} retries. Wait a moment, then send the prompt again to continue. ({label}: {})", - body.trim() + "The model provider is rate-limiting this account (429) and the run could not ride it out after {max_retries} retries. Wait a moment, then send the prompt again to continue. ({label})" )); } - return Err(format!("{label} http {status}: {}", body.trim())); + // Provider error bodies are untrusted and can echo request + // headers. Never relay them into chat/SSE where credentials + // could be reflected back into logs or browser responses. + return Err(format!("{label} http {status}")); } Err(e) => { if e.is_timeout() { @@ -522,6 +521,7 @@ async fn run_openai_chat( /// kill a slow-but-live generation. pub(crate) fn builtin_http_client() -> Result { reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) .connect_timeout(BUILTIN_HTTP_CONNECT_TIMEOUT) .timeout(BUILTIN_HTTP_REQUEST_TIMEOUT) .build() @@ -704,8 +704,12 @@ fn parse_openai_sse_data(data: &str) -> Option { }); } let value: Value = serde_json::from_str(data).ok()?; - if let Some(message) = value.pointer("/error/message").and_then(Value::as_str) { - return Some(ChatDelta::Error(message.to_string())); + if value.get("error").is_some() { + // A provider-controlled HTTP-200 SSE event can reflect request headers + // or credentials in its message. Preserve only the error boundary. + return Some(ChatDelta::Error( + "OpenAI-compatible provider reported a stream error".into(), + )); } let choice = value.get("choices")?.as_array()?.first()?; if let Some(delta) = choice.get("delta") { @@ -761,10 +765,9 @@ fn parse_anthropic_sse_data(data: &str) -> Option { "message_stop" => Some(ChatDelta::Done { stop_reason: StopReason::EndTurn, }), - "error" => value - .pointer("/error/message") - .and_then(Value::as_str) - .map(|message| ChatDelta::Error(message.to_string())), + "error" => Some(ChatDelta::Error( + "Anthropic provider reported a stream error".into(), + )), _ => None, } } diff --git a/crates/op-host-services/src/chat_builtin_http_tests.rs b/crates/op-host-services/src/chat_builtin_http_tests.rs index a47c259b2..ed3ae8316 100644 --- a/crates/op-host-services/src/chat_builtin_http_tests.rs +++ b/crates/op-host-services/src/chat_builtin_http_tests.rs @@ -262,6 +262,82 @@ fn request_timeout_does_not_consume_available_retry_budget() { ); } +#[test] +fn provider_does_not_follow_redirects_or_echo_upstream_error_bodies() { + let redirected = TcpListener::bind("127.0.0.1:0").expect("bind redirect target"); + redirected + .set_nonblocking(true) + .expect("nonblocking redirect target"); + let redirected_addr = redirected.local_addr().expect("redirect target address"); + let (followed_tx, followed_rx) = std_mpsc::channel(); + let redirected_server = std::thread::spawn(move || { + let deadline = std::time::Instant::now() + Duration::from_secs(1); + while std::time::Instant::now() < deadline { + match redirected.accept() { + Ok((mut stream, _)) => { + let _ = read_http_request(&mut stream); + let body = "data: {\"choices\":[{\"finish_reason\":\"stop\"}]}\n\n"; + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).unwrap(); + followed_tx.send(true).unwrap(); + return; + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("redirect target accept failed: {error}"), + } + } + followed_tx.send(false).unwrap(); + }); + + let origin = TcpListener::bind("127.0.0.1:0").expect("bind redirect origin"); + let origin_addr = origin.local_addr().expect("redirect origin address"); + let origin_server = std::thread::spawn(move || { + let (mut stream, _) = origin.accept().expect("accept origin request"); + let _ = read_http_request(&mut stream); + let body = r#"{"error":"echoed credential sk-test"}"#; + let response = format!( + "HTTP/1.1 302 Found\r\nLocation: http://{redirected_addr}/v1/chat/completions\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).unwrap(); + }); + + let mut provider = ConfiguredBuiltinProvider::from_builtin_agent(&builtin_config( + BuiltinAgentKind::OpenAiCompat, + format!("http://{origin_addr}/v1"), + )) + .expect("ready redirecting provider"); + provider.max_retries = 0; + provider.min_gap = Duration::ZERO; + + let deltas: Vec<_> = provider + .send(ChatRequest { + user_message: "hello".into(), + ..Default::default() + }) + .collect(); + origin_server.join().expect("origin server exits"); + let followed = followed_rx.recv().expect("redirect result"); + redirected_server.join().expect("redirect target exits"); + + assert!(!followed, "provider client must not follow redirects"); + let error = deltas + .iter() + .find_map(|delta| match delta { + ChatDelta::Error(message) => Some(message.as_str()), + _ => None, + }) + .expect("redirect must surface a status-only error"); + assert!(error.contains("302"), "unexpected error: {error}"); + assert!(!error.contains("sk-test"), "credential leaked: {error}"); + assert!(!error.contains("echoed credential"), "body leaked: {error}"); +} + #[test] fn parse_openai_sse_data_extracts_text_delta() { let data = r#"{"choices":[{"delta":{"content":"hello"}}]}"#; @@ -280,6 +356,36 @@ fn parse_anthropic_sse_data_extracts_text_delta() { ); } +#[test] +fn openai_sse_error_does_not_reflect_upstream_message() { + let sentinel = "SENTINEL_sk-browser-secret"; + let data = format!(r#"{{"error":{{"message":"provider echoed {sentinel}"}}}}"#); + + let delta = parse_openai_sse_data(&data).expect("error event parses"); + + assert_eq!( + delta, + ChatDelta::Error("OpenAI-compatible provider reported a stream error".into()) + ); + assert!(!format!("{delta:?}").contains(sentinel)); +} + +#[test] +fn anthropic_sse_error_does_not_reflect_upstream_message() { + let sentinel = "SENTINEL_anthropic-browser-secret"; + let data = format!( + r#"{{"type":"error","error":{{"type":"invalid_request_error","message":"provider echoed {sentinel}"}}}}"# + ); + + let delta = parse_anthropic_sse_data(&data).expect("error event parses"); + + assert_eq!( + delta, + ChatDelta::Error("Anthropic provider reported a stream error".into()) + ); + assert!(!format!("{delta:?}").contains(sentinel)); +} + #[test] fn is_minimax_model_gates_thinking_field() { // 仅 MiniMax 模型加 `thinking:{type:disabled}`;别的 provider 不加。 @@ -415,7 +521,7 @@ fn openai_sse_error_finishes_aborted() { assert!( deltas .iter() - .any(|d| matches!(d, ChatDelta::Error(message) if message == "bad key")), + .any(|d| matches!(d, ChatDelta::Error(message) if message == "OpenAI-compatible provider reported a stream error")), "expected upstream error delta, got {deltas:?}" ); assert!( diff --git a/crates/op-host-services/src/lib.rs b/crates/op-host-services/src/lib.rs index 5127f428e..e55a4ed8f 100644 --- a/crates/op-host-services/src/lib.rs +++ b/crates/op-host-services/src/lib.rs @@ -59,5 +59,7 @@ pub mod settings_io; pub mod validation_providers; pub mod web_canvas_server; pub mod web_chat_standard; +pub mod web_credential_policy; +pub mod web_credentials; pub mod web_static; pub mod zode_import; diff --git a/crates/op-host-services/src/mcp_serve.rs b/crates/op-host-services/src/mcp_serve.rs index efae87c50..df041efac 100644 --- a/crates/op-host-services/src/mcp_serve.rs +++ b/crates/op-host-services/src/mcp_serve.rs @@ -278,24 +278,17 @@ pub struct HttpRequest { pub method: String, pub path: String, pub body: String, + pub host: Option, + pub origin: Option, } -/// Read an HTTP request off `stream`. Reads to the `\r\n\r\n` header -/// terminator, parses the request line + `Content-Length`, then reads -/// exactly that many body bytes. The header block is capped so a -/// malformed peer can't exhaust memory. +/// Parse a capped HTTP header and then read exactly its declared body length. +/// Query strings are stripped from the path before routing. pub fn read_http_request(stream: &mut S) -> Result { const MAX_HEADER: usize = 64 * 1024; - // Body ceiling. A whole-document live sync (`/api/mcp/document`), or an - // `insert_node` carrying an embedded base64 image, is legitimately large — - // realistic image-heavy designs run to tens of MiB, so the old 8 MiB cap - // rejected them. 64 MiB comfortably accepts realistic documents while - // bounding peak memory: the doc-sync path parses the body into a - // `serde_json::Value` and reserializes the inner document before the - // canonical load, so actual peak is a few× the body — a 64 MiB ceiling - // keeps that within a sane envelope for this localhost, single-user server. - // The body is read incrementally (below), so a lying `Content-Length` can't - // force an up-front allocation. + // Whole-document sync can carry embedded images, so retain the 64 MiB + // ceiling while reading incrementally to avoid allocating from a claimed + // Content-Length alone. const MAX_BODY: usize = 64 * 1024 * 1024; let mut head: Vec = Vec::new(); let mut byte = [0u8; 1]; @@ -347,17 +340,28 @@ pub fn read_http_request(stream: &mut S) -> Result crate::web_credentials::MAX_CREDENTIAL_BODY_BYTES + { + return Err("credential settings body exceeds 256 KiB".into()); + } + let header_value = |wanted: &str| { + headers.lines().skip(1).find_map(|line| { + let (name, value) = line.trim().split_once(':')?; + name.eq_ignore_ascii_case(wanted) + .then(|| value.trim().to_string()) + }) + }; + let host = header_value("host"); + let origin = header_value("origin"); if content_length > MAX_BODY { return Err(format!( "request body exceeds {} MiB", MAX_BODY / (1024 * 1024) )); } - // Read the body incrementally so memory tracks bytes ACTUALLY received, - // not the peer-declared Content-Length: a lying or oversized length can't - // force a big up-front allocation — the buffer grows only as bytes arrive, - // and a stalled peer trips the socket read timeout instead of pinning a - // thread indefinitely. + // Grow the body from bytes actually received, not the declared length; + // socket timeouts bound a stalled peer. let mut body = Vec::with_capacity(content_length.min(64 * 1024)); let mut remaining = content_length; let mut chunk = [0u8; 64 * 1024]; @@ -376,6 +380,8 @@ pub fn read_http_request(stream: &mut S) -> Result( Access-Control-Allow-Headers: Content-Type, mcp-session-id\r\n\ Access-Control-Expose-Headers: mcp-session-id\r\n\ mcp-session-id: openpencil\r\n\ + Cache-Control: no-store\r\n\ Content-Type: application/json\r\n\ Content-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), diff --git a/crates/op-host-services/src/mcp_serve/tests.rs b/crates/op-host-services/src/mcp_serve/tests.rs index bd14e8394..6be171dec 100644 --- a/crates/op-host-services/src/mcp_serve/tests.rs +++ b/crates/op-host-services/src/mcp_serve/tests.rs @@ -825,6 +825,24 @@ fn http_request_still_rejects_an_over_cap_content_length() { assert!(err.contains("exceeds"), "{err}"); } +#[test] +fn credential_settings_request_rejects_over_256_kib_before_reading_body() { + let request = concat!( + "POST /api/settings/credentials HTTP/1.1\r\n", + "Host: x\r\n", + "Content-Length: 262145\r\n", + "\r\n" + ); + let mut cur = std::io::Cursor::new(request.as_bytes().to_vec()); + + let err = read_http_request(&mut cur).expect_err("oversized credential body must be rejected"); + + assert!( + err.contains("credential settings body exceeds 256 KiB"), + "{err}" + ); +} + #[test] fn http_transport_serves_initialize() { let rpc = r#"{"jsonrpc":"2.0","id":7,"method":"initialize","params":{}}"#; @@ -848,6 +866,7 @@ fn http_transport_serves_initialize() { assert!(resp.contains("Content-Type: application/json")); assert!(resp.contains("mcp-session-id: openpencil")); assert!(resp.contains("Access-Control-Allow-Origin: *")); + assert!(resp.contains("Cache-Control: no-store")); // The JSON-RPC initialize reply carries the protocol handshake + // the request id, proving the body round-tripped over HTTP. assert!(resp.contains(r#""protocolVersion""#), "body: {resp}"); diff --git a/crates/op-host-services/src/model_discovery.rs b/crates/op-host-services/src/model_discovery.rs index e8a7092ab..ddb7cc815 100644 --- a/crates/op-host-services/src/model_discovery.rs +++ b/crates/op-host-services/src/model_discovery.rs @@ -303,7 +303,10 @@ pub fn codex_models_from_app_server() -> Option> { // `initialize` (id 1) → `initialized` notification → // `model/list` (id 2). stdin is held open until the response // lands so the server doesn't shut down mid-refresh. - let init = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"openpencil","version":"0.8.0"},"capabilities":{"experimentalApi":true}}}"#; + let init = format!( + r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"clientInfo":{{"name":"openpencil","version":"{}"}},"capabilities":{{"experimentalApi":true}}}}}}"#, + env!("CARGO_PKG_VERSION") + ); let initialized = r#"{"jsonrpc":"2.0","method":"initialized"}"#; let list = r#"{"jsonrpc":"2.0","id":2,"method":"model/list","params":{}}"#; writeln!(stdin, "{init}").ok()?; diff --git a/crates/op-host-services/src/settings_io.rs b/crates/op-host-services/src/settings_io.rs index 415295188..af57e17e1 100644 --- a/crates/op-host-services/src/settings_io.rs +++ b/crates/op-host-services/src/settings_io.rs @@ -4,7 +4,9 @@ //! All preferences live on `EditorState.editor_ui` — the host's //! single source of truth. -use std::path::PathBuf; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; use op_editor_core::editor_ui_state::{RecentFile, RECENT_FILE_CAP}; use op_editor_core::{ @@ -13,6 +15,9 @@ use op_editor_core::{ }; use serde::{Deserialize, Serialize}; +#[path = "settings_io_checked.rs"] +mod settings_io_checked; + #[derive(Debug, Serialize, Deserialize, Clone)] struct RecentFilePayload { path: String, @@ -77,6 +82,7 @@ pub struct Fingerprint { images_adv: bool, openverse_client_id: String, openverse_client_secret: String, + openverse_credential_owner: Option, auto_update_enabled: bool, experimental_features_enabled: bool, connected: [bool; 5], @@ -96,6 +102,7 @@ pub fn fingerprint(state: &EditorState) -> Fingerprint { images_adv: eui.agent_settings.images_advanced_open, openverse_client_id: eui.agent_settings.openverse_client_id.clone(), openverse_client_secret: eui.agent_settings.openverse_client_secret.clone(), + openverse_credential_owner: eui.agent_settings.openverse_credential_owner.clone(), auto_update_enabled: eui.agent_settings.auto_update_enabled, experimental_features_enabled: eui.agent_settings.experimental_features_enabled, connected: eui.agent_settings.connected, @@ -115,6 +122,7 @@ pub fn save_if_changed(state: &EditorState, before: Fingerprint) { const SETTINGS_VERSION: u32 = 1; const APP_DIR: &str = "openpencil"; const FILE_NAME: &str = "settings.json"; +static SETTINGS_TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); #[derive(Debug, Serialize, Deserialize)] struct SettingsPayload { @@ -132,6 +140,8 @@ struct SettingsPayload { #[serde(default)] openverse_oauth: Option, #[serde(default)] + openverse_credential_owner: Option, + #[serde(default)] auto_update_enabled: Option, #[serde(default)] experimental_features_enabled: Option, @@ -171,6 +181,7 @@ fn to_payload(state: &EditorState) -> SettingsPayload { mcp_cli_enabled: Some(eui.agent_settings.mcp_cli_enabled), images_advanced_open: Some(eui.agent_settings.images_advanced_open), openverse_oauth: openverse_oauth_to_payload(&eui.agent_settings), + openverse_credential_owner: eui.agent_settings.openverse_credential_owner.clone(), auto_update_enabled: Some(eui.agent_settings.auto_update_enabled), experimental_features_enabled: Some(eui.agent_settings.experimental_features_enabled), connected: Some(eui.agent_settings.connected), @@ -214,6 +225,14 @@ fn to_payload(state: &EditorState) -> SettingsPayload { } fn apply_payload(state: &mut EditorState, payload: SettingsPayload) { + apply_payload_with_options(state, payload, true); +} + +fn apply_payload_with_options( + state: &mut EditorState, + payload: SettingsPayload, + dedupe_builtins: bool, +) { if payload.version != SETTINGS_VERSION { return; } @@ -237,6 +256,7 @@ fn apply_payload(state: &mut EditorState, payload: SettingsPayload) { eui.agent_settings.openverse_client_id = oauth.client_id; eui.agent_settings.openverse_client_secret = oauth.client_secret; } + eui.agent_settings.openverse_credential_owner = payload.openverse_credential_owner; if let Some(b) = payload.auto_update_enabled { eui.agent_settings.auto_update_enabled = b; } @@ -251,7 +271,11 @@ fn apply_payload(state: &mut EditorState, payload: SettingsPayload) { .into_iter() .filter_map(builtin_agent_from_payload) .collect(); - eui.agent_settings.builtin_agents = dedupe_builtin_agents(agents); + eui.agent_settings.builtin_agents = if dedupe_builtins { + dedupe_builtin_agents(agents) + } else { + agents + }; eui.agent_settings.next_builtin_agent_id = next_builtin_agent_id(&eui.agent_settings.builtin_agents); } @@ -492,6 +516,41 @@ fn next_image_gen_profile_id(profiles: &[ImageGenProfile]) -> u64 { .saturating_add(1) } +fn load_checked_from_path(state: &mut EditorState, path: &Path) -> Result<(), String> { + let bytes = match std::fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(format!("failed to read settings file: {error}")), + }; + let raw: serde_json::Value = serde_json::from_slice(&bytes) + .map_err(|error| format!("failed to parse settings file: {error}"))?; + settings_io_checked::validate_payload_fields(&raw)?; + let payload: SettingsPayload = serde_json::from_value(raw) + .map_err(|error| format!("failed to parse settings file: {error}"))?; + if payload.version != SETTINGS_VERSION { + return Err(format!( + "unsupported settings file version {}; expected {SETTINGS_VERSION}", + payload.version + )); + } + settings_io_checked::validate_lossless_payload(&payload)?; + apply_payload_with_options(state, payload, false); + Ok(()) +} + +/// Strict load used by web startup. A missing file is a normal first-run state, +/// but an existing file must be readable and losslessly loadable so the daemon +/// cannot later overwrite unknown settings or miss browser-owned credentials. +pub fn load_checked(state: &mut EditorState) -> Result<(), String> { + if let Some(detected) = detect_system_locale() { + state.editor_ui.locale = detected; + } + let path = settings_path().ok_or_else(|| "failed to resolve settings file path".to_string())?; + load_checked_from_path(state, &path)?; + crate::zode_import::import_zode_builtin_agents(state); + Ok(()) +} + /// Best-effort load. Returns silently on missing file / parse error. pub fn load(state: &mut EditorState) { // Seed the locale from the OS BEFORE the settings file is read. @@ -556,21 +615,111 @@ fn locale_from_tag(raw: &str) -> Option { str_to_locale(lang) } -/// Best-effort save. Returns silently on IO failure. -pub fn save(state: &EditorState) { - let Some(path) = settings_path() else { return }; +/// Persist settings and report any failure to the caller. +pub fn save_checked(state: &EditorState) -> Result<(), String> { + let path = settings_path().ok_or_else(|| "settings path is unavailable".to_string())?; + save_checked_to_path(state, &path) +} + +struct PendingSettingsFile { + path: PathBuf, + file: Option, +} + +impl PendingSettingsFile { + fn file_mut(&mut self) -> &mut std::fs::File { + self.file + .as_mut() + .expect("pending settings file must stay open until replacement") + } + + fn close(&mut self) { + drop(self.file.take()); + } +} + +impl Drop for PendingSettingsFile { + fn drop(&mut self) { + self.close(); + let _ = std::fs::remove_file(&self.path); + } +} + +fn create_unique_settings_temp(path: &Path) -> Result { + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + let file_name = path + .file_name() + .map(|name| name.to_string_lossy()) + .unwrap_or_else(|| "settings.json".into()); + + for _ in 0..128 { + let sequence = SETTINGS_TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let tmp_path = parent.join(format!( + ".{file_name}.{}.{}.tmp", + std::process::id(), + sequence + )); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + + match options.open(&tmp_path) { + Ok(file) => { + let pending = PendingSettingsFile { + path: tmp_path, + file: Some(file), + }; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + pending + .file + .as_ref() + .expect("new settings file must be open") + .set_permissions(std::fs::Permissions::from_mode(0o600)) + .map_err(|error| { + format!("failed to secure temporary settings file: {error}") + })?; + } + return Ok(pending); + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => { + return Err(format!("failed to create temporary settings file: {error}")); + } + } + } + + Err("failed to allocate a unique temporary settings file".to_string()) +} + +fn save_checked_to_path(state: &EditorState, path: &Path) -> Result<(), String> { if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); + std::fs::create_dir_all(parent) + .map_err(|error| format!("failed to create settings directory: {error}"))?; } let payload = to_payload(state); - let Ok(json) = serde_json::to_string_pretty(&payload) else { - return; - }; - let mut tmp = path.clone(); - tmp.set_extension("json.tmp"); - if std::fs::write(&tmp, json).is_ok() { - let _ = std::fs::rename(&tmp, &path); + let json = serde_json::to_string_pretty(&payload) + .map_err(|error| format!("failed to encode settings: {error}"))?; + let mut tmp = create_unique_settings_temp(path)?; + if let Err(error) = tmp.file_mut().write_all(json.as_bytes()) { + return Err(format!("failed to write temporary settings: {error}")); } + tmp.close(); + if let Err(error) = std::fs::rename(&tmp.path, path) { + return Err(format!("failed to replace settings file: {error}")); + } + Ok(()) +} + +/// Best-effort save for existing callers that do not surface persistence +/// failures to a request boundary. +pub fn save(state: &EditorState) { + let _ = save_checked(state); } fn theme_to_str(t: ThemeMode) -> &'static str { diff --git a/crates/op-host-services/src/settings_io_checked.rs b/crates/op-host-services/src/settings_io_checked.rs new file mode 100644 index 000000000..de96ccbb3 --- /dev/null +++ b/crates/op-host-services/src/settings_io_checked.rs @@ -0,0 +1,220 @@ +use super::*; + +pub(super) fn validate_payload_fields(raw: &serde_json::Value) -> Result<(), String> { + let Some(root) = raw.as_object() else { + return Ok(()); + }; + validate_known_fields( + root, + &[ + "version", + "theme", + "locale", + "mcp_port", + "mcp_cli_enabled", + "images_advanced_open", + "openverse_oauth", + "openverse_credential_owner", + "auto_update_enabled", + "experimental_features_enabled", + "connected", + "builtin_agents", + "acp_agents", + "image_gen_profiles", + "active_image_gen_profile_id", + "recent_files", + ], + "root", + )?; + validate_optional_object_fields( + root.get("openverse_oauth"), + &["client_id", "client_secret"], + "Openverse OAuth", + )?; + validate_array_object_fields( + root.get("builtin_agents"), + &[ + "id", + "preset", + "display_name", + "kind", + "api_key", + "model", + "base_url", + "enabled", + ], + "built-in agent", + )?; + validate_array_object_fields( + root.get("acp_agents"), + &[ + "id", + "display_name", + "connection_type", + "command", + "args", + "env", + "url", + "enabled", + ], + "ACP agent", + )?; + validate_array_object_fields( + root.get("image_gen_profiles"), + &["id", "name", "provider", "api_key", "model", "base_url"], + "image profile", + )?; + validate_array_object_fields( + root.get("recent_files"), + &["path", "modified_at"], + "recent file", + ) +} + +fn validate_optional_object_fields( + value: Option<&serde_json::Value>, + allowed: &[&str], + context: &str, +) -> Result<(), String> { + if let Some(object) = value.and_then(serde_json::Value::as_object) { + validate_known_fields(object, allowed, context)?; + } + Ok(()) +} + +fn validate_array_object_fields( + value: Option<&serde_json::Value>, + allowed: &[&str], + context: &str, +) -> Result<(), String> { + if let Some(entries) = value.and_then(serde_json::Value::as_array) { + for entry in entries { + if let Some(object) = entry.as_object() { + validate_known_fields(object, allowed, context)?; + } + } + } + Ok(()) +} + +fn validate_known_fields( + object: &serde_json::Map, + allowed: &[&str], + context: &str, +) -> Result<(), String> { + if object + .keys() + .any(|field| !allowed.contains(&field.as_str())) + { + return Err(format!("unknown settings field in {context}")); + } + Ok(()) +} + +pub(super) fn validate_lossless_payload(payload: &SettingsPayload) -> Result<(), String> { + let lossy = || "settings file cannot be loaded losslessly".to_string(); + validate_credential_entries(payload)?; + if payload + .theme + .as_deref() + .is_some_and(|theme| !matches!(theme, "dark" | "light")) + { + return Err(lossy()); + } + if payload.locale.as_deref().is_some_and(|saved| { + str_to_locale(saved).is_none_or(|locale| locale_to_str(locale) != saved) + }) { + return Err(lossy()); + } + if payload.mcp_port.is_some_and(|port| port < 1024) + || payload + .recent_files + .as_ref() + .is_some_and(|recent| recent.len() > RECENT_FILE_CAP) + { + return Err(lossy()); + } + if payload.builtin_agents.as_ref().is_some_and(|agents| { + agents.iter().any(|agent| { + agent + .preset + .as_deref() + .is_some_and(|preset| BuiltinAgentPresetKey::from_str(preset).is_none()) + }) + }) { + return Err(lossy()); + } + if payload + .active_image_gen_profile_id + .as_deref() + .is_some_and(|active| { + !payload + .image_gen_profiles + .as_ref() + .is_some_and(|profiles| profiles.iter().any(|profile| profile.id == active)) + }) + { + return Err(lossy()); + } + if payload + .image_gen_profiles + .as_ref() + .is_some_and(|profiles| !profiles.is_empty()) + && payload.active_image_gen_profile_id.is_none() + { + return Err(lossy()); + } + if payload.openverse_oauth.as_ref().is_some_and(|oauth| { + oauth.client_id.trim() != oauth.client_id + || oauth.client_secret.trim() != oauth.client_secret + || (oauth.client_id.is_empty() && oauth.client_secret.is_empty()) + }) { + return Err(lossy()); + } + Ok(()) +} + +fn validate_credential_entries(payload: &SettingsPayload) -> Result<(), String> { + fn invalid() -> String { + "unsupported settings credential entry".to_string() + } + fn lossy() -> String { + "settings file cannot be loaded losslessly".to_string() + } + if let Some(agents) = payload.builtin_agents.as_ref() { + for agent in agents { + if !matches!(agent.kind.as_str(), "anthropic" | "openai-compat") { + if builtin_agent_from_payload(agent.clone()).is_some() { + return Err(lossy()); + } + return Err(invalid()); + } + let parsed = builtin_agent_from_payload(agent.clone()).ok_or_else(invalid)?; + let saved = agent + .preset + .as_deref() + .and_then(BuiltinAgentPresetKey::from_str) + .ok_or_else(lossy)?; + if parsed.preset != saved { + return Err(lossy()); + } + } + } + if payload.acp_agents.as_ref().is_some_and(|agents| { + agents + .iter() + .cloned() + .any(|agent| acp_agent_from_payload(agent).is_none()) + }) { + return Err(invalid()); + } + if payload.image_gen_profiles.as_ref().is_some_and(|profiles| { + profiles + .iter() + .cloned() + .any(|profile| image_gen_profile_from_payload(profile).is_none()) + }) { + return Err(invalid()); + } + Ok(()) +} diff --git a/crates/op-host-services/src/settings_io_checked_tests.rs b/crates/op-host-services/src/settings_io_checked_tests.rs new file mode 100644 index 000000000..24f12ddff --- /dev/null +++ b/crates/op-host-services/src/settings_io_checked_tests.rs @@ -0,0 +1,440 @@ +use super::super::*; + +fn checked_settings_test_path(case: &str) -> std::path::PathBuf { + let sequence = SETTINGS_TEMP_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + std::env::temp_dir().join(format!( + "openpencil-settings-checked-{}-{sequence}-{case}", + std::process::id() + )) +} + +#[test] +fn checked_settings_load_rejects_a_malformed_existing_file_without_mutation() { + let root = checked_settings_test_path("malformed"); + let path = root.join("settings.json"); + std::fs::create_dir_all(&root).expect("create settings test directory"); + std::fs::write(&path, br#"{"version":1,"builtin_agents":["#).expect("write malformed settings"); + let mut state = EditorState::new(); + let before = fingerprint(&state); + + let error = load_checked_from_path(&mut state, &path) + .expect_err("strict web settings load must reject malformed JSON"); + + assert!( + error.contains("parse settings"), + "unexpected error: {error}" + ); + assert_eq!(before, fingerprint(&state)); + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn checked_settings_load_rejects_a_schema_invalid_existing_file() { + let root = checked_settings_test_path("invalid-schema"); + let path = root.join("settings.json"); + std::fs::create_dir_all(&root).expect("create settings test directory"); + std::fs::write(&path, br#"{"version":"one"}"#).expect("write schema-invalid settings"); + + let error = load_checked_from_path(&mut EditorState::new(), &path) + .expect_err("strict web settings load must reject schema-invalid JSON"); + + assert!( + error.contains("parse settings"), + "unexpected error: {error}" + ); + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn checked_settings_load_rejects_an_unsupported_settings_version() { + let root = checked_settings_test_path("future-version"); + let path = root.join("settings.json"); + std::fs::create_dir_all(&root).expect("create settings test directory"); + std::fs::write(&path, br#"{"version":2}"#).expect("write future settings"); + + let error = load_checked_from_path(&mut EditorState::new(), &path) + .expect_err("web startup must not overwrite an unsupported settings schema"); + + assert!( + error.contains("unsupported settings file version"), + "unexpected error: {error}" + ); + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn checked_settings_load_rejects_unknown_fields_at_every_settings_nesting_level() { + let cases = vec![ + ( + "top", + serde_json::json!({"version":1,"future_setting":true}), + ), + ( + "recent", + serde_json::json!({ + "version":1, + "recent_files":[{"path":"/tmp/design.op","modified_at":1,"future":true}] + }), + ), + ( + "builtin", + serde_json::json!({ + "version":1, + "builtin_agents":[{ + "id":"builtin-1","preset":"custom","display_name":"Built-in", + "kind":"openai-compat","api_key":"key","model":"model", + "base_url":"https://api.openai.com/v1","enabled":true,"future":true + }] + }), + ), + ( + "acp", + serde_json::json!({ + "version":1, + "acp_agents":[{ + "id":"acp-1","display_name":"Native ACP","connection_type":"local", + "command":"agent","args":[],"env":{"TOKEN":"secret"},"url":null, + "enabled":true,"future":true + }] + }), + ), + ( + "image", + serde_json::json!({ + "version":1, + "image_gen_profiles":[{ + "id":"igp-1","name":"Image","provider":"openai","api_key":"key", + "model":"gpt-image-1","base_url":null,"future":true + }] + }), + ), + ( + "openverse", + serde_json::json!({ + "version":1, + "openverse_oauth":{"client_id":"client","client_secret":"secret","future":true} + }), + ), + ]; + + for (case, value) in cases { + // The native best-effort schema remains additive-compatible. + serde_json::from_value::(value.clone()) + .expect("native settings parser keeps ignoring additive fields"); + + let root = checked_settings_test_path(case); + let path = root.join("settings.json"); + std::fs::create_dir_all(&root).expect("create settings test directory"); + std::fs::write(&path, serde_json::to_vec(&value).expect("encode settings")) + .expect("write settings"); + let mut state = EditorState::new(); + let before = fingerprint(&state); + + let error = load_checked_from_path(&mut state, &path) + .expect_err("strict web load must reject unknown same-version fields"); + + assert!( + error.contains("unknown settings field"), + "case={case}, {error}" + ); + assert_eq!(before, fingerprint(&state), "case={case}"); + let _ = std::fs::remove_dir_all(root); + } +} + +#[test] +fn checked_settings_load_rejects_values_that_would_be_silently_normalized() { + let too_many_recent = (0..=RECENT_FILE_CAP) + .map(|index| serde_json::json!({"path":format!("/tmp/{index}.op"),"modified_at":index})) + .collect::>(); + let cases = vec![ + ("theme", serde_json::json!({"version":1,"theme":"sepia"})), + ( + "locale-unknown", + serde_json::json!({"version":1,"locale":"xx"}), + ), + ( + "locale-alias", + serde_json::json!({"version":1,"locale":"en"}), + ), + ("port", serde_json::json!({"version":1,"mcp_port":80})), + ( + "recent-cap", + serde_json::json!({"version":1,"recent_files":too_many_recent}), + ), + ( + "active-image", + serde_json::json!({ + "version":1, + "image_gen_profiles":[{ + "id":"igp-1","name":"Image","provider":"openai","api_key":"key", + "model":"gpt-image-1","base_url":null + }], + "active_image_gen_profile_id":"igp-missing" + }), + ), + ( + "active-image-null", + serde_json::json!({ + "version":1, + "image_gen_profiles":[{ + "id":"igp-1","name":"Image","provider":"openai","api_key":"key", + "model":"gpt-image-1","base_url":null + }], + "active_image_gen_profile_id":null + }), + ), + ( + "builtin-preset", + serde_json::json!({ + "version":1, + "builtin_agents":[{ + "id":"builtin-1","preset":"future-preset","display_name":"Built-in", + "kind":"openai-compat","api_key":"key","model":"model", + "base_url":"https://api.openai.com/v1","enabled":true + }] + }), + ), + ( + "builtin-missing-preset", + serde_json::json!({ + "version":1, + "builtin_agents":[{ + "id":"builtin-1","display_name":"Built-in", + "kind":"openai-compat","api_key":"key","model":"model", + "base_url":"https://api.openai.com/v1","enabled":true + }] + }), + ), + ( + "builtin-mismatched-preset", + serde_json::json!({ + "version":1, + "builtin_agents":[{ + "id":"builtin-1","preset":"doubao","display_name":"Built-in", + "kind":"openai-compat","api_key":"key","model":"ark-code-latest", + "base_url":"https://ark.cn-beijing.volces.com/api/coding", + "enabled":true + }] + }), + ), + ( + "builtin-kind-alias-openai", + serde_json::json!({ + "version":1, + "builtin_agents":[{ + "id":"builtin-1","preset":"custom","display_name":"Built-in", + "kind":"openai","api_key":"key","model":"model", + "base_url":"https://api.openai.com/v1","enabled":true + }] + }), + ), + ( + "builtin-kind-alias-underscore", + serde_json::json!({ + "version":1, + "builtin_agents":[{ + "id":"builtin-1","preset":"custom","display_name":"Built-in", + "kind":"openai_compat","api_key":"key","model":"model", + "base_url":"https://api.openai.com/v1","enabled":true + }] + }), + ), + ( + "openverse-whitespace", + serde_json::json!({ + "version":1, + "openverse_oauth":{"client_id":" client ","client_secret":" secret "} + }), + ), + ( + "openverse-empty", + serde_json::json!({ + "version":1, + "openverse_oauth":{"client_id":"","client_secret":""} + }), + ), + ]; + + for (case, value) in cases { + let root = checked_settings_test_path(case); + let path = root.join("settings.json"); + std::fs::create_dir_all(&root).expect("create settings test directory"); + std::fs::write(&path, serde_json::to_vec(&value).expect("encode settings")) + .expect("write settings"); + let mut state = EditorState::new(); + let before = fingerprint(&state); + + let error = load_checked_from_path(&mut state, &path) + .expect_err("strict web load must reject a lossy settings normalization"); + + assert!(error.contains("losslessly"), "case={case}, {error}"); + assert_eq!(before, fingerprint(&state), "case={case}"); + let _ = std::fs::remove_dir_all(root); + } +} + +#[test] +fn checked_settings_load_rejects_semantically_invalid_credential_entries() { + let cases = [ + ( + "builtin-kind", + r#"{ + "version":1, + "builtin_agents":[{ + "id":"web-credential:builtin:future", + "display_name":"Future Builtin", + "kind":"future-provider", + "api_key":"browser-secret", + "model":"future-model", + "base_url":"https://future.example/v1", + "enabled":true + }] + }"#, + ), + ( + "acp-connection", + r#"{ + "version":1, + "acp_agents":[{ + "id":"acp-future", + "display_name":"Future ACP", + "connection_type":"future-transport", + "command":"/opt/browser-agent", + "enabled":true + }] + }"#, + ), + ( + "image-provider", + r#"{ + "version":1, + "image_gen_profiles":[{ + "id":"web-credential:image:future", + "name":"Future Image", + "provider":"future-provider", + "api_key":"browser-image-secret", + "model":"future-image" + }] + }"#, + ), + ]; + + for (case, body) in cases { + let root = checked_settings_test_path(case); + let path = root.join("settings.json"); + std::fs::create_dir_all(&root).expect("create settings test directory"); + std::fs::write(&path, body).expect("write semantically invalid settings"); + let mut state = EditorState::new(); + let before = fingerprint(&state); + + let error = load_checked_from_path(&mut state, &path) + .expect_err("web startup must not silently drop an unknown credential entry"); + + assert!( + error.contains("unsupported settings credential entry"), + "case={case}, error={error}" + ); + assert_eq!(before, fingerprint(&state), "case={case}"); + let _ = std::fs::remove_dir_all(root); + } +} + +#[test] +fn checked_settings_load_preserves_app_generated_operator_and_browser_duplicates() { + let root = checked_settings_test_path("valid-duplicate"); + let path = root.join("settings.json"); + std::fs::create_dir_all(&root).expect("create settings test directory"); + let mut persisted = EditorState::new(); + persisted.editor_ui.agent_settings.add_builtin_agent_config( + "Shared", + "same-key", + "same-model", + BuiltinAgentKind::OpenAiCompat, + "https://api.openai.com/v1", + ); + let mut browser = persisted.editor_ui.agent_settings.builtin_agents[0].clone(); + browser.id = "web-credential:builtin:shared".into(); + persisted + .editor_ui + .agent_settings + .builtin_agents + .push(browser); + let body = serde_json::to_vec(&to_payload(&persisted)).expect("encode app settings"); + std::fs::write(&path, body).expect("write app-generated settings"); + let mut loaded = EditorState::new(); + + load_checked_from_path(&mut loaded, &path) + .expect("checked web load accepts app-generated duplicate configurations"); + + assert_eq!(loaded.editor_ui.agent_settings.builtin_agents.len(), 2); + assert!(loaded + .editor_ui + .agent_settings + .builtin_agents + .iter() + .any(|agent| agent.id == "web-credential:builtin:shared")); + assert!(crate::web_credentials::remove_browser_owned_credentials( + &mut loaded + )); + assert_eq!(loaded.editor_ui.agent_settings.builtin_agents.len(), 1); + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn checked_settings_load_keeps_the_existing_native_acp_schema_unchanged() { + let root = checked_settings_test_path("native-acp"); + let path = root.join("settings.json"); + std::fs::create_dir_all(&root).expect("create settings test directory"); + let mut persisted = EditorState::new(); + persisted.editor_ui.agent_settings.add_acp_agent_config( + "Native ACP", + AcpConnectionType::Local, + "/usr/local/bin/native-agent", + vec!["--stdio".into()], + std::collections::BTreeMap::from([("TOKEN".into(), "native-secret".into())]), + None, + true, + ); + let body = serde_json::to_vec(&to_payload(&persisted)).expect("encode native settings"); + let encoded: serde_json::Value = serde_json::from_slice(&body).expect("decode native settings"); + assert!(encoded["acp_agents"][0].get("credential_owner").is_none()); + std::fs::write(&path, body).expect("write native settings"); + let mut loaded = EditorState::new(); + + load_checked_from_path(&mut loaded, &path).expect("native ACP settings remain valid"); + + let agent = &loaded.editor_ui.agent_settings.acp_agents[0]; + assert_eq!(agent.command, "/usr/local/bin/native-agent"); + assert_eq!(agent.args, vec!["--stdio"]); + assert_eq!( + agent.env.get("TOKEN").map(String::as_str), + Some("native-secret") + ); + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn checked_settings_load_rejects_an_unreadable_existing_path_without_mutation() { + let root = checked_settings_test_path("unreadable"); + let path = root.join("settings.json"); + std::fs::create_dir_all(&path).expect("create directory at settings file path"); + let mut state = EditorState::new(); + let before = fingerprint(&state); + + let error = load_checked_from_path(&mut state, &path) + .expect_err("strict web settings load must reject an unreadable settings path"); + + assert!(error.contains("read settings"), "unexpected error: {error}"); + assert_eq!(before, fingerprint(&state)); + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn checked_settings_load_allows_a_missing_file() { + let root = checked_settings_test_path("missing"); + let path = root.join("settings.json"); + let mut state = EditorState::new(); + + load_checked_from_path(&mut state, &path).expect("missing settings is a first-run state"); +} diff --git a/crates/op-host-services/src/settings_io_tests.rs b/crates/op-host-services/src/settings_io_tests.rs index 029261540..5b7e90ef6 100644 --- a/crates/op-host-services/src/settings_io_tests.rs +++ b/crates/op-host-services/src/settings_io_tests.rs @@ -1,5 +1,7 @@ use super::*; +#[path = "settings_io_checked_tests.rs"] +mod checked_tests; #[test] fn persisted_locale_overrides_system_locale_seed() { assert_eq!( @@ -351,6 +353,7 @@ fn openverse_oauth_round_trips_through_payload() { let mut src = EditorState::new(); src.editor_ui.agent_settings.openverse_client_id = "client-id".into(); src.editor_ui.agent_settings.openverse_client_secret = "client-secret".into(); + src.editor_ui.agent_settings.openverse_credential_owner = Some("browser".into()); let json = serde_json::to_string(&to_payload(&src)).unwrap(); let payload: SettingsPayload = serde_json::from_str(&json).unwrap(); @@ -365,6 +368,100 @@ fn openverse_oauth_round_trips_through_payload() { dst.editor_ui.agent_settings.openverse_client_secret, "client-secret" ); + assert_eq!( + dst.editor_ui + .agent_settings + .openverse_credential_owner + .as_deref(), + Some("browser") + ); +} + +#[test] +fn checked_save_reports_an_unwritable_parent() { + let root = std::env::temp_dir().join(format!( + "openpencil-settings-save-{}-{}", + std::process::id(), + std::thread::current().name().unwrap_or("unnamed") + )); + let blocking_parent = root.join("not-a-directory"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(&blocking_parent, b"block").unwrap(); + let path = blocking_parent.join("settings.json"); + + let result = save_checked_to_path(&EditorState::new(), &path); + + assert!(result.is_err()); + let _ = std::fs::remove_dir_all(&root); +} + +#[cfg(unix)] +#[test] +fn checked_save_does_not_reuse_an_existing_temporary_path() { + let root = std::env::temp_dir().join(format!( + "openpencil-settings-unique-temp-{}-{}", + std::process::id(), + std::thread::current().name().unwrap_or("unnamed") + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + let path = root.join("settings.json"); + let tmp = root.join("settings.json.tmp"); + std::fs::write(&tmp, b"do not replace").unwrap(); + + let result = save_checked_to_path(&EditorState::new(), &path); + + assert!(result.is_ok()); + assert_eq!(std::fs::read(&tmp).unwrap(), b"do not replace"); + assert!(path.is_file()); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn checked_save_removes_the_temporary_file_after_a_replace_failure() { + let root = std::env::temp_dir().join(format!( + "openpencil-settings-replace-failure-{}-{}", + std::process::id(), + std::thread::current().name().unwrap_or("unnamed") + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + let path = root.join("settings.json"); + std::fs::create_dir(&path).unwrap(); + + let result = save_checked_to_path(&EditorState::new(), &path); + + assert!(result.is_err()); + let remaining = std::fs::read_dir(&root) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect::>(); + assert_eq!(remaining, vec![path]); + let _ = std::fs::remove_dir_all(&root); +} + +#[cfg(unix)] +#[test] +fn checked_save_enforces_private_permissions_on_the_final_file() { + use std::os::unix::fs::PermissionsExt; + + let root = std::env::temp_dir().join(format!( + "openpencil-settings-private-mode-{}-{}", + std::process::id(), + std::thread::current().name().unwrap_or("unnamed") + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + let path = root.join("settings.json"); + let old_tmp = root.join("settings.json.tmp"); + std::fs::write(&old_tmp, b"old temporary contents").unwrap(); + std::fs::set_permissions(&old_tmp, std::fs::Permissions::from_mode(0o666)).unwrap(); + + save_checked_to_path(&EditorState::new(), &path).unwrap(); + + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + let _ = std::fs::remove_dir_all(&root); } #[test] diff --git a/crates/op-host-services/src/web_canvas_server.rs b/crates/op-host-services/src/web_canvas_server.rs index 48f3ea037..9ad66dad4 100644 --- a/crates/op-host-services/src/web_canvas_server.rs +++ b/crates/op-host-services/src/web_canvas_server.rs @@ -25,8 +25,9 @@ use std::thread; use std::time::Duration; use base64::Engine as _; -use op_editor_core::agent_settings::{AcpAgentConnectOutcome, ProviderConnectOutcome}; -use op_editor_core::EditorState; +use op_editor_core::{AgentSettings, EditorState}; + +use crate::web_credential_policy::WebCredentialPersistence; /// Slow/stalled-peer bound — bodies can be large (whole documents with embedded /// images), so a connection that opens and dribbles must not pin a thread. @@ -94,6 +95,9 @@ impl Drop for ConnGuard { /// read/replace it over `/api/mcp/document`. pub struct WebCanvasState { pub(crate) editor: EditorState, + /// Immutable deployment policy for browser-supplied credentials. Public + /// demo deployments fail closed to browser-only persistence. + pub(crate) credential_persistence: WebCredentialPersistence, /// Local file backing this daemon document, when `--serve-web` was launched /// with a path or the user opened a recent local file through the daemon. pub(crate) current_path: Option, @@ -111,13 +115,41 @@ impl WebCanvasState { Self::new_with_path(editor, port, None) } + #[cfg(test)] + pub(crate) fn new_with_policy( + editor: EditorState, + port: u16, + credential_persistence: WebCredentialPersistence, + ) -> Self { + Self::new_with_path_and_policy(editor, port, None, credential_persistence) + } + + #[cfg(test)] pub(crate) fn new_with_path( editor: EditorState, port: u16, current_path: Option, ) -> Self { + Self::new_with_path_and_policy( + editor, + port, + current_path, + WebCredentialPersistence::BrowserOnly, + ) + } + + fn new_with_path_and_policy( + mut editor: EditorState, + port: u16, + current_path: Option, + credential_persistence: WebCredentialPersistence, + ) -> Self { + if !credential_persistence.server_persistence() { + let _ = crate::web_credentials::remove_browser_owned_credentials(&mut editor); + } Self { editor, + credential_persistence, current_path, version: 0, port, @@ -155,6 +187,35 @@ pub struct WebReply { pub(crate) body: String, } +fn persist_api_settings( + method: &str, + path: &str, + state: &mut WebCanvasState, + settings_before: crate::settings_io::Fingerprint, + credential_settings_before: Option, + reply: WebReply, + save_credentials: F, +) -> WebReply +where + F: FnOnce(&EditorState) -> Result<(), String>, +{ + if method == "POST" && path == "/api/settings/credentials" && reply.status == "200 OK" { + if save_credentials(&state.editor).is_err() { + if let Some(settings) = credential_settings_before { + state.editor.editor_ui.agent_settings = settings; + state.editor.rebuild_chat_models(); + } + return WebReply { + status: "500 Internal Server Error", + body: crate::mcp_serve::rest_error_body("failed to persist server credentials"), + }; + } + } else { + crate::settings_io::save_if_changed(&state.editor, settings_before); + } + reply +} + /// Handle one parsed web-canvas REST request against the in-memory state. Pure /// w.r.t. IO — fully unit-testable without a socket. Mirrors the TS Nitro /// routes: @@ -286,16 +347,6 @@ pub fn handle_web_canvas_request( ("POST", "/api/file/open-recent") => open_recent_file(body, state), ("POST", "/api/export/pdf") => export_pdf_download(body, state), ("POST", "/api/export/raster") => export_raster_download(body, state), - ("POST", "/api/agents/connect") => { - handle_provider_connect_request_with_probe(body, state, crate::provider_probe::connect_provider) - } - ("POST", "/api/acp/connect") => { - handle_acp_agent_connect_request_with_probe( - body, - state, - crate::acp_agent_probe_host::probe_acp_agent_config, - ) - } ("GET", "/api/ai/models") => WebReply { // JSON array of model ids the AI proxy can serve (the // configured built-in agents). The web bundle queries this @@ -305,6 +356,40 @@ pub fn handle_web_canvas_request( status: "200 OK", body: crate::ai_proxy::models_json(&state.editor), }, + ("GET", "/api/settings/credential-policy") => WebReply { + status: "200 OK", + body: serde_json::json!({ + "serverPersistence": state.credential_persistence.server_persistence(), + }) + .to_string(), + }, + ("POST", "/api/settings/credentials") => { + if !state.credential_persistence.server_persistence() { + WebReply { + status: "403 Forbidden", + body: crate::mcp_serve::rest_error_body( + "server credential persistence is disabled", + ), + } + } else { + match crate::web_credentials::apply_json(&mut state.editor, body) { + Ok(()) => WebReply { + status: "200 OK", + body: r#"{"ok":true}"#.into(), + }, + Err(message) => WebReply { + status: if body.len() + > crate::web_credentials::MAX_CREDENTIAL_BODY_BYTES + { + "413 Payload Too Large" + } else { + "400 Bad Request" + }, + body: crate::mcp_serve::rest_error_body(&message), + }, + } + } + } _ => WebReply { status: "404 Not Found", body: r#"{"ok":false,"error":"Not found. Use /api/mcp/document, /api/mcp/sync-reset, /api/mcp/server, /api/file/save, /api/export/raster, /api/export/pdf, or /mcp."}"# @@ -637,248 +722,6 @@ fn update_mcp_server_settings(body: &str, state: &mut WebCanvasState) -> WebRepl } } -pub fn handle_acp_agent_connect_request_with_probe( - body: &str, - state: &mut WebCanvasState, - probe: F, -) -> WebReply -where - F: FnOnce(op_acp::AcpAgentConfig) -> crate::acp_agent_probe_host::AcpAgentProbeOutcome, -{ - let Some(id) = parse_acp_agent_connect_request(body) else { - return WebReply { - status: "400 Bad Request", - body: crate::mcp_serve::rest_error_body("Missing ACP agent id"), - }; - }; - let Some(index) = state - .editor - .editor_ui - .agent_settings - .acp_agents - .iter() - .position(|agent| agent.id == id && agent.ready()) - else { - return WebReply { - status: "400 Bad Request", - body: crate::mcp_serve::rest_error_body("ACP agent is not configured"), - }; - }; - let agent = state.editor.editor_ui.agent_settings.acp_agents[index].clone(); - state - .editor - .editor_ui - .agent_settings - .begin_acp_agent_connect(index); - let outcome = probe(crate::acp_agent_probe_host::acp_config_for_probe(&agent)); - apply_acp_agent_probe_outcome(&id, outcome, state) -} - -fn parse_acp_agent_connect_request(body: &str) -> Option { - let parsed: serde_json::Value = serde_json::from_str(body).ok()?; - parsed - .get("id") - .or_else(|| parsed.get("agentId")) - .and_then(|v| v.as_str()) - .filter(|id| !id.trim().is_empty()) - .map(str::to_string) -} - -fn apply_acp_agent_probe_outcome( - id: &str, - outcome: crate::acp_agent_probe_host::AcpAgentProbeOutcome, - state: &mut WebCanvasState, -) -> WebReply { - state - .editor - .editor_ui - .agent_settings - .apply_acp_agent_connect_outcome( - id, - AcpAgentConnectOutcome { - connected: outcome.connected, - info: outcome.info.clone(), - error: outcome.error.clone(), - }, - ); - state.editor.rebuild_chat_models(); - WebReply { - status: "200 OK", - body: serde_json::json!({ - "ok": true, - "id": id, - "connected": outcome.connected, - "connectionInfo": outcome.info, - "error": outcome.error, - }) - .to_string(), - } -} - -pub fn handle_provider_connect_request_with_probe( - body: &str, - state: &mut WebCanvasState, - probe: F, -) -> WebReply -where - F: FnOnce(op_ai::agent_settings_state::AgentProvider) -> crate::provider_probe::ProbeOutcome, -{ - let Some(provider) = parse_provider_connect_request(body) else { - return WebReply { - status: "400 Bad Request", - body: crate::mcp_serve::rest_error_body("Missing provider"), - }; - }; - state - .editor - .editor_ui - .agent_settings - .begin_provider_connect(provider); - let outcome = probe(provider_to_probe(provider)); - apply_provider_probe_outcome(provider, outcome, state) -} - -fn parse_provider_connect_request(body: &str) -> Option { - let parsed: serde_json::Value = serde_json::from_str(body).ok()?; - parsed - .get("provider") - .and_then(|v| v.as_str()) - .and_then(parse_agent_provider) -} - -fn parse_agent_provider(raw: &str) -> Option { - use op_editor_core::AgentProvider; - let normalized = raw - .chars() - .filter(|c| c.is_ascii_alphanumeric()) - .flat_map(|c| c.to_lowercase()) - .collect::(); - match normalized.as_str() { - "claude" | "claudecode" => Some(AgentProvider::ClaudeCode), - "codex" | "codexcli" => Some(AgentProvider::CodexCli), - "opencode" => Some(AgentProvider::OpenCode), - "githubcopilot" | "copilot" => Some(AgentProvider::GithubCopilot), - "gemini" | "geminicli" => Some(AgentProvider::GeminiCli), - _ => None, - } -} - -fn provider_to_probe( - provider: op_editor_core::AgentProvider, -) -> op_ai::agent_settings_state::AgentProvider { - use op_ai::agent_settings_state::AgentProvider as ProbeProvider; - use op_editor_core::AgentProvider; - match provider { - AgentProvider::ClaudeCode => ProbeProvider::ClaudeCode, - AgentProvider::CodexCli => ProbeProvider::CodexCli, - AgentProvider::OpenCode => ProbeProvider::OpenCode, - AgentProvider::GithubCopilot => ProbeProvider::GithubCopilot, - AgentProvider::GeminiCli => ProbeProvider::GeminiCli, - } -} - -fn apply_provider_probe_outcome( - provider: op_editor_core::AgentProvider, - outcome: crate::provider_probe::ProbeOutcome, - state: &mut WebCanvasState, -) -> WebReply { - let outcome = crate::provider_probe_host::normalize_provider_probe_outcome(provider, outcome); - let crate::provider_probe::ProbeOutcome { - connected, - models, - error, - warning, - not_installed, - install_command, - connection_info, - hint_path, - version, - } = outcome; - let response_models: Vec = models - .iter() - .map(|m| { - serde_json::json!({ - "provider": provider_key(provider), - "value": m.value, - "displayName": m.display_name, - }) - }) - .collect(); - state - .editor - .editor_ui - .agent_settings - .apply_provider_connect_outcome( - provider, - ProviderConnectOutcome { - connected, - info: connection_info.clone(), - warning: warning.clone(), - error: error.clone(), - not_installed, - install_command: install_command.clone(), - hint_path: hint_path.clone(), - version: version.clone(), - }, - ); - state - .editor - .editor_ui - .agent_settings - .pending_provider_connect = None; - if connected && !models.is_empty() { - state - .editor - .chat - .discovered_models - .retain(|m| m.provider != provider); - state.editor.chat.discovered_models.extend( - models - .into_iter() - .map(crate::model_discovery::model_entry_to_ec), - ); - sort_discovered_models(&mut state.editor); - } - state.editor.rebuild_chat_models(); - WebReply { - status: "200 OK", - body: serde_json::json!({ - "ok": true, - "provider": provider_key(provider), - "connected": connected, - "models": response_models, - "error": error, - "warning": warning, - "notInstalled": not_installed, - "installCommand": install_command, - "connectionInfo": connection_info, - "hintPath": hint_path, - "version": version, - }) - .to_string(), - } -} - -fn sort_discovered_models(editor: &mut EditorState) { - editor.chat.discovered_models.sort_by_key(|m| { - op_editor_core::AgentProvider::ALL - .iter() - .position(|p| *p == m.provider) - .unwrap_or(usize::MAX) - }); -} - -fn provider_key(provider: op_editor_core::AgentProvider) -> &'static str { - use op_editor_core::AgentProvider; - match provider { - AgentProvider::ClaudeCode => "claude", - AgentProvider::CodexCli => "codex", - AgentProvider::OpenCode => "opencode", - AgentProvider::GithubCopilot => "github-copilot", - AgentProvider::GeminiCli => "gemini", - } -} - fn open_recent_file(body: &str, state: &mut WebCanvasState) -> WebReply { let parsed: Option = serde_json::from_str(body).ok(); let Some(path_s) = parsed @@ -1075,12 +918,30 @@ fn startup_editor_from_base_for_web_canvas( } } -pub fn startup_editor_for_web_canvas(path: Option) -> Result { +fn startup_editor_for_web_canvas_with_loader( + path: Option, + _policy: WebCredentialPersistence, + checked_load: Checked, +) -> Result +where + Checked: FnOnce(&mut EditorState) -> Result<(), String>, +{ let mut base = EditorState::starter(); - crate::settings_io::load(&mut base); + checked_load(&mut base)?; startup_editor_from_base_for_web_canvas(base, path) } +fn startup_editor_for_web_canvas_with_policy( + path: Option, + policy: WebCredentialPersistence, +) -> Result { + startup_editor_for_web_canvas_with_loader(path, policy, crate::settings_io::load_checked) +} + +pub fn startup_editor_for_web_canvas(path: Option) -> Result { + startup_editor_for_web_canvas_with_policy(path, crate::web_credential_policy::from_env()) +} + /// Run the web-canvas daemon on `host:port` (default `127.0.0.1`), backed by /// the document at `path` (or the starter document when `None`). Serves the /// static host page + bundle, the whole-document REST sync + health routes, @@ -1088,7 +949,13 @@ pub fn startup_editor_for_web_canvas(path: Option) -> Result, port: u16, host: &str) -> Result<(), String> { let current_path = path.clone(); - let editor = startup_editor_for_web_canvas(path)?; + let credential_persistence = crate::web_credential_policy::from_env(); + let mut editor = startup_editor_for_web_canvas_with_policy(path, credential_persistence)?; + enforce_credential_persistence_policy( + &mut editor, + credential_persistence, + crate::settings_io::save_checked, + )?; let listener = TcpListener::bind((host, port)).map_err(|e| format!("bind {host}:{port}: {e}"))?; let bound = listener.local_addr().map(|a| a.port()).unwrap_or(port); @@ -1106,10 +973,11 @@ pub fn run_web_canvas(path: Option, port: u16, host: &str) -> Result<() // Shared across connection threads: the document authority (one writer at a // time via the Mutex) + the SSE broadcast hub. Thread-per-connection so a // long-lived SSE stream (or a slow client) never blocks other clients. - let state = Arc::new(Mutex::new(WebCanvasState::new_with_path( + let state = Arc::new(Mutex::new(WebCanvasState::new_with_path_and_policy( editor, bound, current_path, + credential_persistence, ))); let hub = Arc::new(SseHub::default()); let conn_count = Arc::new(AtomicUsize::new(0)); @@ -1169,6 +1037,25 @@ pub fn run_web_canvas(path: Option, port: u16, host: &str) -> Result<() Ok(()) } +fn enforce_credential_persistence_policy( + editor: &mut EditorState, + policy: WebCredentialPersistence, + save: F, +) -> Result<(), String> +where + F: FnOnce(&EditorState) -> Result<(), String>, +{ + if !policy.server_persistence() + && crate::web_credentials::remove_browser_owned_credentials(editor) + { + save(editor).map_err(|_| { + "failed to remove browser-owned credentials while server persistence is disabled" + .to_string() + })?; + } + Ok(()) +} + /// Handle one connection. Routes: static host page + wasm bundle (`GET /`, /// `GET /pkg/*` via `crate::web_static`); SSE live-update stream (`GET /// /api/mcp/events`); REST whole-doc sync / health (`/api/*` via @@ -1190,6 +1077,14 @@ fn serve_one( return crate::mcp_serve::write_mcp_http_response(stream, "204 No Content", "") .map(|()| false); } + if is_sensitive_browser_post(&req) && !credential_request_origin_allowed(&req) { + return crate::mcp_serve::write_mcp_http_response( + stream, + "403 Forbidden", + &crate::mcp_serve::rest_error_body("cross-origin sensitive request is forbidden"), + ) + .map(|()| false); + } // Static serving: the host page (`/`) and the wasm-bindgen bundle // (`/pkg/*`). Owns only those paths — everything else falls through. if req.method == "GET" { @@ -1224,12 +1119,24 @@ fn serve_one( }; let provider = { let guard = state.lock().unwrap_or_else(|p| p.into_inner()); - crate::ai_proxy::proxy_provider(&guard.editor, &ai_req.model) + crate::ai_proxy::proxy_provider_for_request( + &guard.editor, + &ai_req, + guard.credential_persistence, + ) }; - let Some(provider) = provider else { - return crate::ai_proxy::write_sse_error(stream, "no model configured") - .map_err(|e| format!("ai stream error: {e}")) - .map(|()| false); + let provider = match provider { + Ok(Some(provider)) => provider, + Ok(None) => { + return crate::ai_proxy::write_sse_error(stream, "no model configured") + .map_err(|e| format!("ai stream error: {e}")) + .map(|()| false); + } + Err(message) => { + return crate::ai_proxy::write_sse_error(stream, &message) + .map_err(|e| format!("ai stream error: {e}")) + .map(|()| false); + } }; return crate::ai_proxy::stream_ai_response(stream, ai_req, provider.as_ref()) .map_err(|e| format!("ai stream: {e}")) @@ -1249,76 +1156,6 @@ fn serve_one( .map_err(|e| format!("ai standard: {e}")) .map(|()| false); } - if req.method == "POST" && req.path == "/api/agents/connect" { - let Some(provider) = parse_provider_connect_request(&req.body) else { - return crate::mcp_serve::write_mcp_http_response( - stream, - "400 Bad Request", - &crate::mcp_serve::rest_error_body("Missing provider"), - ) - .map(|()| false); - }; - let outcome = crate::provider_probe::connect_provider(provider_to_probe(provider)); - let reply = { - let mut guard = state.lock().unwrap_or_else(|p| p.into_inner()); - guard - .editor - .editor_ui - .agent_settings - .begin_provider_connect(provider); - let reply = apply_provider_probe_outcome(provider, outcome, &mut guard); - crate::settings_io::save(&guard.editor); - reply - }; - return crate::mcp_serve::write_mcp_http_response(stream, reply.status, &reply.body) - .map(|()| false); - } - if req.method == "POST" && req.path == "/api/acp/connect" { - let Some(id) = parse_acp_agent_connect_request(&req.body) else { - return crate::mcp_serve::write_mcp_http_response( - stream, - "400 Bad Request", - &crate::mcp_serve::rest_error_body("Missing ACP agent id"), - ) - .map(|()| false); - }; - let agent = { - let mut guard = state.lock().unwrap_or_else(|p| p.into_inner()); - let Some(index) = guard - .editor - .editor_ui - .agent_settings - .acp_agents - .iter() - .position(|agent| agent.id == id && agent.ready()) - else { - return crate::mcp_serve::write_mcp_http_response( - stream, - "400 Bad Request", - &crate::mcp_serve::rest_error_body("ACP agent is not configured"), - ) - .map(|()| false); - }; - let agent = guard.editor.editor_ui.agent_settings.acp_agents[index].clone(); - guard - .editor - .editor_ui - .agent_settings - .begin_acp_agent_connect(index); - agent - }; - let outcome = crate::acp_agent_probe_host::probe_acp_agent_config( - crate::acp_agent_probe_host::acp_config_for_probe(&agent), - ); - let reply = { - let mut guard = state.lock().unwrap_or_else(|p| p.into_inner()); - let reply = apply_acp_agent_probe_outcome(&id, outcome, &mut guard); - crate::settings_io::save(&guard.editor); - reply - }; - return crate::mcp_serve::write_mcp_http_response(stream, reply.status, &reply.body) - .map(|()| false); - } // All `/api/mcp/*` REST paths go to the REST handler — including ones this // daemon doesn't implement yet, which it answers with 404 rather than // mis-routing them into the JSON-RPC dispatch below. @@ -1326,7 +1163,20 @@ fn serve_one( let reply = { let mut guard = state.lock().unwrap_or_else(|p| p.into_inner()); let before = guard.version; + let settings_before = crate::settings_io::fingerprint(&guard.editor); + let credential_settings_before = (req.method == "POST" + && req.path == "/api/settings/credentials") + .then(|| guard.editor.editor_ui.agent_settings.clone()); let reply = handle_web_canvas_request(&req.method, &req.path, &req.body, &mut guard); + let reply = persist_api_settings( + &req.method, + &req.path, + &mut guard, + settings_before, + credential_settings_before, + reply, + crate::settings_io::save_checked, + ); // Broadcast INSIDE the state lock so the version bump and its // broadcast are atomic — otherwise two concurrent mutations could // broadcast their versions out of order (SSE clients seeing N then @@ -1430,6 +1280,100 @@ fn serve_one( crate::mcp_serve::write_mcp_http_response(stream, status, &response).map(|()| false) } +const WEB_ALLOWED_ORIGINS_ENV: &str = "OPENPENCIL_WEB_ALLOWED_ORIGINS"; + +fn is_sensitive_browser_post(request: &crate::mcp_serve::HttpRequest) -> bool { + request.method == "POST" + && (request.path == "/api/settings/credentials" || request.path.starts_with("/api/ai/")) +} + +fn credential_request_origin_allowed(request: &crate::mcp_serve::HttpRequest) -> bool { + let allowed_origins = std::env::var(WEB_ALLOWED_ORIGINS_ENV).ok(); + credential_request_origin_allowed_with_config(request, allowed_origins.as_deref()) +} + +fn credential_request_origin_allowed_with_config( + request: &crate::mcp_serve::HttpRequest, + allowed_origins: Option<&str>, +) -> bool { + let Some(origin) = request.origin.as_deref() else { + // Non-browser clients do not normally send Origin. Server persistence + // is an opt-in private-deployment feature, so those clients remain + // usable while browser requests are constrained by the unforgeable + // Origin header. + return true; + }; + let Some(host) = request.host.as_deref() else { + return false; + }; + let Some(origin) = parse_http_origin(origin) else { + return false; + }; + let Ok(host) = reqwest::Url::parse(&format!("http://{host}/")) else { + return false; + }; + let same_request_authority = + origin + .host_str() + .zip(host.host_str()) + .is_some_and(|(origin_host, request_host)| { + let request_port = host.port().or_else(|| match origin.scheme() { + "http" => Some(80), + "https" => Some(443), + _ => None, + }); + origin_host.eq_ignore_ascii_case(request_host) + && origin.port_or_known_default() == request_port + }); + if !same_request_authority { + return false; + } + if origin.host_str().is_some_and(is_loopback_web_host) { + return true; + } + allowed_origins + .into_iter() + .flat_map(|origins| origins.split(',')) + .filter_map(|configured| parse_http_origin(configured.trim())) + .any(|configured| same_url_origin(&origin, &configured)) +} + +fn parse_http_origin(value: &str) -> Option { + let origin = reqwest::Url::parse(value).ok()?; + (matches!(origin.scheme(), "http" | "https") + && origin.username().is_empty() + && origin.password().is_none() + && origin.host_str().is_some() + && origin.path() == "/" + && origin.query().is_none() + && origin.fragment().is_none()) + .then_some(origin) +} + +fn same_url_origin(left: &reqwest::Url, right: &reqwest::Url) -> bool { + left.scheme() == right.scheme() + && left + .host_str() + .zip(right.host_str()) + .is_some_and(|(left, right)| left.eq_ignore_ascii_case(right)) + && left.port_or_known_default() == right.port_or_known_default() +} + +fn is_loopback_web_host(host: &str) -> bool { + let ip_literal = host + .strip_prefix('[') + .and_then(|host| host.strip_suffix(']')) + .unwrap_or(host); + host.eq_ignore_ascii_case("localhost") + || host + .to_ascii_lowercase() + .strip_suffix(".localhost") + .is_some_and(|prefix| !prefix.is_empty()) + || ip_literal + .parse::() + .is_ok_and(|address| address.is_loopback()) +} + /// Stream Server-Sent Events to a subscribed client: write the SSE headers, /// emit the current version immediately (initial sync), then forward each /// version bump from `rx` as a `data: {"version":N}` event. A periodic diff --git a/crates/op-host-services/src/web_canvas_server_tests.rs b/crates/op-host-services/src/web_canvas_server_tests.rs index f3de30d55..163511938 100644 --- a/crates/op-host-services/src/web_canvas_server_tests.rs +++ b/crates/op-host-services/src/web_canvas_server_tests.rs @@ -7,9 +7,32 @@ fn fresh_state() -> WebCanvasState { WebCanvasState::new(EditorState::new(), 3100) } +fn fresh_server_persistence_state() -> WebCanvasState { + WebCanvasState::new_with_policy( + EditorState::new(), + 3100, + crate::web_credential_policy::WebCredentialPersistence::Server, + ) +} + // A minimal canonical document body in the TS `setSyncDocument` shape. const SYNC_BODY: &str = r##"{"document":{"version":"1.0.0","children":[{"id":"n9","type":"rectangle","name":"Synced Rect","x":1,"y":2,"width":80,"height":40,"fill":[{"type":"solid","color":"#123456"}]}]},"sourceClientId":"web"}"##; +const CREDENTIAL_BODY: &str = r#"{ + "version":2, + "builtin_agents":[{ + "id":"builtin-web-1","preset":"custom","display_name":"Private Model", + "kind":"openai-compat","api_key":"sk-browser-only","model":"private-model", + "base_url":"https://api.openai.com/v1","enabled":true + }], + "image_gen_profiles":[{ + "id":"igp-web-1","name":"Image","provider":"openai", + "api_key":"image-browser-only","model":"gpt-image-1","base_url":null + }], + "active_image_gen_profile_id":"igp-web-1", + "openverse_oauth":{"client_id":"client","client_secret":"openverse-browser-only"} +}"#; + fn write_temp_op(name: &str, body: &str) -> PathBuf { let path = std::env::temp_dir().join(temp_op_file_name( name, @@ -68,6 +91,316 @@ fn server_health_matches_ts_running_port_shape() { assert!(r.body.contains(r#""server":"openpencil-mcp""#)); } +#[test] +fn credential_policy_endpoint_reports_only_the_boolean() { + let mut state = WebCanvasState::new_with_policy( + EditorState::new(), + 3100, + crate::web_credential_policy::WebCredentialPersistence::BrowserOnly, + ); + let reply = handle_web_canvas_request("GET", "/api/settings/credential-policy", "", &mut state); + + assert_eq!(reply.status, "200 OK"); + assert_eq!(reply.body, r#"{"serverPersistence":false}"#); + assert!(!reply.body.contains("api_key")); + assert!(!reply.body.contains("secret")); +} + +#[test] +fn browser_only_route_rejects_credentials_without_mutating_state() { + let mut state = fresh_state(); + let before = crate::settings_io::fingerprint(&state.editor); + + let reply = handle_web_canvas_request( + "POST", + "/api/settings/credentials", + CREDENTIAL_BODY, + &mut state, + ); + + assert_eq!(reply.status, "403 Forbidden"); + assert_eq!(before, crate::settings_io::fingerprint(&state.editor)); + assert!(!reply.body.contains("sk-browser-only")); +} + +#[test] +fn server_policy_merges_credentials_without_echoing_them() { + let mut state = fresh_server_persistence_state(); + + let reply = handle_web_canvas_request( + "POST", + "/api/settings/credentials", + CREDENTIAL_BODY, + &mut state, + ); + + assert_eq!(reply.status, "200 OK"); + assert_eq!(reply.body, r#"{"ok":true}"#); + let agent = state + .editor + .editor_ui + .agent_settings + .builtin_agents + .iter() + .find(|agent| agent.id.ends_with(":builtin:builtin-web-1")) + .expect("merged browser agent"); + assert_eq!(agent.api_key, "sk-browser-only"); + assert!(!reply.body.contains("browser-only")); +} + +#[test] +fn browser_only_restart_does_not_load_previously_persisted_browser_credentials() { + let mut persisted = EditorState::new(); + crate::web_credentials::apply_json(&mut persisted, CREDENTIAL_BODY) + .expect("private deployment snapshot"); + persisted.editor_ui.agent_settings.add_builtin_agent_config( + "Operator", + "operator-key", + "operator-model", + op_editor_core::BuiltinAgentKind::OpenAiCompat, + "https://operator.example/v1", + ); + + let state = WebCanvasState::new_with_policy( + persisted, + 3100, + crate::web_credential_policy::WebCredentialPersistence::BrowserOnly, + ); + let settings = &state.editor.editor_ui.agent_settings; + + assert!(settings + .builtin_agents + .iter() + .all(|agent| !agent.id.starts_with("web-credential:"))); + assert!(settings + .builtin_agents + .iter() + .any(|agent| agent.model == "operator-model")); + assert!(settings.image_gen_profiles.is_empty()); + assert!(settings.openverse_client_secret.is_empty()); + assert_eq!(settings.openverse_credential_owner, None); +} + +#[test] +fn browser_only_startup_removes_browser_credentials_and_saves_once() { + let mut editor = EditorState::new(); + crate::web_credentials::apply_json(&mut editor, CREDENTIAL_BODY) + .expect("private deployment snapshot"); + editor.editor_ui.agent_settings.add_builtin_agent_config( + "Operator", + "operator-key", + "operator-model", + op_editor_core::BuiltinAgentKind::OpenAiCompat, + "https://operator.example/v1", + ); + let mut saves = 0; + + enforce_credential_persistence_policy( + &mut editor, + crate::web_credential_policy::WebCredentialPersistence::BrowserOnly, + |saved| { + saves += 1; + let settings = &saved.editor_ui.agent_settings; + assert!(settings + .builtin_agents + .iter() + .all(|agent| !agent.id.starts_with("web-credential:"))); + assert!(settings + .builtin_agents + .iter() + .any(|agent| agent.model == "operator-model")); + assert!(settings.image_gen_profiles.is_empty()); + assert!(settings.openverse_client_secret.is_empty()); + Ok(()) + }, + ) + .expect("browser credentials are scrubbed and saved"); + + assert_eq!(saves, 1); +} + +#[test] +fn browser_only_startup_propagates_credential_scrub_save_failure() { + let mut editor = EditorState::new(); + crate::web_credentials::apply_json(&mut editor, CREDENTIAL_BODY) + .expect("private deployment snapshot"); + + let error = enforce_credential_persistence_policy( + &mut editor, + crate::web_credential_policy::WebCredentialPersistence::BrowserOnly, + |_| Err("simulated disk failure".into()), + ) + .expect_err("startup must fail when scrubbed settings cannot be saved"); + + assert_eq!( + error, + "failed to remove browser-owned credentials while server persistence is disabled" + ); + assert!(editor + .editor_ui + .agent_settings + .builtin_agents + .iter() + .all(|agent| !agent.id.starts_with("web-credential:"))); +} + +#[test] +fn server_persistence_startup_keeps_browser_credentials_without_saving() { + let mut editor = EditorState::new(); + crate::web_credentials::apply_json(&mut editor, CREDENTIAL_BODY) + .expect("private deployment snapshot"); + + enforce_credential_persistence_policy( + &mut editor, + crate::web_credential_policy::WebCredentialPersistence::Server, + |_| panic!("server persistence must not rewrite settings at startup"), + ) + .expect("server persistence keeps stored browser credentials"); + + assert!(editor + .editor_ui + .agent_settings + .builtin_agents + .iter() + .any(|agent| agent.id.starts_with("web-credential:"))); +} + +#[test] +fn credential_persistence_failure_rolls_back_and_returns_500_without_echoing_secrets() { + let mut state = fresh_server_persistence_state(); + let settings_before = crate::settings_io::fingerprint(&state.editor); + let agent_settings_before = state.editor.editor_ui.agent_settings.clone(); + let document_children = state.editor.doc.children.as_ptr(); + let reply = handle_web_canvas_request( + "POST", + "/api/settings/credentials", + CREDENTIAL_BODY, + &mut state, + ); + assert_eq!(reply.status, "200 OK"); + + let reply = persist_api_settings( + "POST", + "/api/settings/credentials", + &mut state, + settings_before.clone(), + Some(agent_settings_before), + reply, + |_| Err("simulated disk failure".into()), + ); + + assert_eq!(reply.status, "500 Internal Server Error"); + assert_eq!( + settings_before, + crate::settings_io::fingerprint(&state.editor) + ); + assert_eq!(state.editor.doc.children.as_ptr(), document_children); + assert!(!reply.body.contains("sk-browser-only")); + assert!(!reply.body.contains("simulated disk failure")); +} + +#[test] +fn cross_origin_browser_cannot_write_server_credentials() { + let state = Mutex::new(fresh_server_persistence_state()); + let before = { + let guard = state.lock().unwrap(); + crate::settings_io::fingerprint(&guard.editor) + }; + let request = format!( + "POST /api/settings/credentials HTTP/1.1\r\nHost: 127.0.0.1:3100\r\nOrigin: https://evil.example\r\nContent-Length: {}\r\n\r\n{}", + CREDENTIAL_BODY.len(), + CREDENTIAL_BODY + ); + let request_len = request.len(); + let mut stream = std::io::Cursor::new(request.into_bytes()); + + serve_one(&mut stream, &state, &SseHub::default()).expect("request handled"); + + let response = String::from_utf8_lossy(&stream.get_ref()[request_len..]); + assert!(response.contains("403 Forbidden")); + assert!(response.contains("cross-origin")); + assert!(!response.contains("sk-browser-only")); + let guard = state.lock().unwrap(); + assert_eq!(before, crate::settings_io::fingerprint(&guard.editor)); +} + +#[test] +fn credential_origin_check_allows_default_loopback_and_non_browser_clients() { + for headers in [ + "Host: 127.0.0.1:3100\r\nOrigin: http://127.0.0.1:3100\r\n", + "Host: localhost:3100\r\nOrigin: http://localhost:3100\r\n", + "Host: [::1]:3100\r\nOrigin: http://[::1]:3100\r\n", + "Host: private.example:8443\r\n", + ] { + let request = format!( + "POST /api/settings/credentials HTTP/1.1\r\n{headers}Content-Length: 0\r\n\r\n" + ); + let mut stream = std::io::Cursor::new(request.into_bytes()); + let request = crate::mcp_serve::read_http_request(&mut stream).unwrap(); + assert!( + credential_request_origin_allowed_with_config(&request, None), + "headers={headers:?}" + ); + } +} + +#[test] +fn credential_origin_check_allows_an_explicitly_configured_public_origin() { + let request = "POST /api/settings/credentials HTTP/1.1\r\nHost: demo.example:8443\r\nOrigin: https://demo.example:8443\r\nContent-Length: 0\r\n\r\n"; + let mut stream = std::io::Cursor::new(request.as_bytes()); + let request = crate::mcp_serve::read_http_request(&mut stream).unwrap(); + + assert!(credential_request_origin_allowed_with_config( + &request, + Some("https://other.example, https://demo.example:8443"), + )); +} + +#[test] +fn credential_origin_check_rejects_an_unconfigured_public_same_host_origin() { + let request = "POST /api/settings/credentials HTTP/1.1\r\nHost: evil.example\r\nOrigin: https://evil.example\r\nContent-Length: 0\r\n\r\n"; + let mut stream = std::io::Cursor::new(request.as_bytes()); + let request = crate::mcp_serve::read_http_request(&mut stream).unwrap(); + + assert!(!credential_request_origin_allowed_with_config( + &request, None, + )); +} + +#[test] +fn credential_origin_check_rejects_null_malformed_and_cross_authority_origins() { + for origin in [ + "null", + "://bad", + "https://evil.example", + "file://private.example", + ] { + let request = format!( + "POST /api/settings/credentials HTTP/1.1\r\nHost: private.example\r\nOrigin: {origin}\r\nContent-Length: 0\r\n\r\n" + ); + let mut stream = std::io::Cursor::new(request.into_bytes()); + let request = crate::mcp_serve::read_http_request(&mut stream).unwrap(); + assert!( + !credential_request_origin_allowed_with_config(&request, None), + "origin={origin}" + ); + } +} + +#[test] +fn sensitive_browser_posts_include_credentials_and_ai_routes() { + for path in ["/api/settings/credentials", "/api/ai/stream"] { + let request = crate::mcp_serve::HttpRequest { + method: "POST".into(), + path: path.into(), + body: String::new(), + host: None, + origin: None, + }; + assert!(is_sensitive_browser_post(&request), "path={path}"); + } +} + #[test] fn post_mcp_server_start_stop_updates_daemon_agent_settings() { let mut s = fresh_state(); @@ -127,6 +460,24 @@ fn no_path_startup_uses_the_same_starter_document_as_the_web_shell() { assert_eq!(editor.doc, EditorState::starter().doc); } +#[test] +fn every_web_persistence_policy_propagates_strict_settings_load_failures() { + for policy in [ + crate::web_credential_policy::WebCredentialPersistence::BrowserOnly, + crate::web_credential_policy::WebCredentialPersistence::Server, + ] { + let checked_calls = std::cell::Cell::new(0); + let result = startup_editor_for_web_canvas_with_loader(None, policy, |_| { + checked_calls.set(checked_calls.get() + 1); + Err("invalid existing settings".into()) + }); + + let error = result.expect_err("all web policies must fail closed on invalid settings"); + assert_eq!(error, "invalid existing settings"); + assert_eq!(checked_calls.get(), 1, "policy={policy:?}"); + } +} + #[test] fn post_document_replaces_doc_and_bumps_version() { use op_editor_core::PenNodeExt; @@ -234,17 +585,36 @@ fn unknown_route_404s() { #[test] fn get_ai_models_returns_json_array() { - // The AI proxy model list is served as a JSON array — empty - // when nothing is configured, but always well-formed JSON the - // web bundle can `JSON.parse`. - let r = handle_web_canvas_request("GET", "/api/ai/models", "", &mut fresh_state()); + let mut state = fresh_state(); + state + .editor + .editor_ui + .agent_settings + .add_builtin_agent_with_defaults("Built-in", "sk-test", "built-in-model"); + state.editor.chat.discovered_models = vec![op_editor_core::ModelEntry::new( + op_editor_core::AgentProvider::ClaudeCode, + "cli-model", + "CLI model", + )]; + state + .editor + .editor_ui + .agent_settings + .apply_provider_connect_outcome( + op_editor_core::AgentProvider::ClaudeCode, + op_editor_core::ProviderConnectOutcome { + connected: true, + info: Some("Connected via CLI".into()), + ..Default::default() + }, + ); + state.editor.rebuild_chat_models(); + + let r = handle_web_canvas_request("GET", "/api/ai/models", "", &mut state); assert!(r.status.starts_with("200")); - let parsed: serde_json::Value = - serde_json::from_str(&r.body).expect("models body is valid JSON"); - assert!( - parsed.is_array(), - "models body must be a JSON array: {}", - r.body + assert_eq!( + serde_json::from_str::>(&r.body).expect("models body is valid JSON"), + vec!["built-in-model"] ); } @@ -406,175 +776,17 @@ fn png_dimensions(bytes: &[u8]) -> (u32, u32) { } #[test] -fn provider_connect_probe_response_updates_agent_settings_and_models() { - use op_ai::agent_settings_state::AgentProvider as ProbeProvider; - use op_ai::chat_models::ModelEntry as ProbeModelEntry; - use op_editor_core::agent_settings::ProviderConnectPhase; - use op_editor_core::AgentProvider; +fn web_cli_and_acp_connect_routes_are_unavailable_in_both_dispatchers() { + for path in ["/api/agents/connect", "/api/acp/connect"] { + let direct = handle_web_canvas_request("POST", path, "{}", &mut fresh_state()); + assert_eq!(direct.status, "404 Not Found", "path={path}"); - let mut s = fresh_state(); - let body = serde_json::json!({ "provider": "codex" }).to_string(); - let r = handle_provider_connect_request_with_probe(&body, &mut s, |_| { - crate::provider_probe::ProbeOutcome { - connected: true, - models: vec![ProbeModelEntry::new( - ProbeProvider::CodexCli, - "gpt-5.5", - "GPT-5.5", - )], - connection_info: Some("Connected via Codex CLI".to_string()), - version: Some("codex 1.2.3".to_string()), - ..Default::default() - } - }); - - assert!(r.status.starts_with("200"), "{}", r.body); - let parsed: serde_json::Value = serde_json::from_str(&r.body).expect("json body"); - assert_eq!(parsed["ok"], true); - assert_eq!(parsed["connected"], true); - assert_eq!(parsed["models"][0]["value"], "gpt-5.5"); - - let idx = - op_editor_core::agent_settings::AgentSettings::provider_index(AgentProvider::CodexCli); - let settings = &s.editor.editor_ui.agent_settings; - assert!(settings.provider_verified_connected(AgentProvider::CodexCli)); - assert_eq!( - settings.provider_connection[idx].phase, - ProviderConnectPhase::Connected - ); - assert!(s - .editor - .chat - .discovered_models - .iter() - .any(|m| m.provider == AgentProvider::CodexCli && m.value == "gpt-5.5")); - assert!(s - .editor - .chat - .available_models - .iter() - .any(|m| m.provider == AgentProvider::CodexCli && m.value == "gpt-5.5")); -} - -#[test] -fn provider_connect_probe_response_without_models_is_failure() { - use op_editor_core::agent_settings::ProviderConnectPhase; - use op_editor_core::AgentProvider; - - let mut s = fresh_state(); - s.editor - .chat - .discovered_models - .push(op_editor_core::ModelEntry::new( - AgentProvider::CodexCli, - "stale-gpt", - "Stale GPT", - )); - let body = serde_json::json!({ "provider": "codex" }).to_string(); - let r = handle_provider_connect_request_with_probe(&body, &mut s, |_| { - crate::provider_probe::ProbeOutcome { - connected: true, - connection_info: Some("Connected via Codex CLI".to_string()), - version: Some("codex 1.2.3".to_string()), - ..Default::default() - } - }); - - assert!(r.status.starts_with("200"), "{}", r.body); - let parsed: serde_json::Value = serde_json::from_str(&r.body).expect("json body"); - assert_eq!(parsed["connected"], false); - assert!( - parsed["error"] - .as_str() - .is_some_and(|error| error.contains("No models found")), - "{}", - r.body - ); - - let settings = &s.editor.editor_ui.agent_settings; - assert!(!settings.provider_verified_connected(AgentProvider::CodexCli)); - let idx = - op_editor_core::agent_settings::AgentSettings::provider_index(AgentProvider::CodexCli); - assert_eq!( - settings.provider_connection[idx].phase, - ProviderConnectPhase::Error - ); - assert!( - !s.editor - .chat - .available_models - .iter() - .any(|m| m.provider == AgentProvider::CodexCli), - "stale Codex models must not become selectable after a failed connect" - ); -} - -#[test] -fn acp_connect_probe_response_updates_agent_settings() { - use op_editor_core::agent_settings::{AcpAgentConnectPhase, AcpConnectionType}; - use std::collections::BTreeMap; - - let mut s = fresh_state(); - s.editor.editor_ui.agent_settings.add_acp_agent_config( - "Claude Code", - AcpConnectionType::Local, - "claude", - Vec::new(), - BTreeMap::new(), - None, - true, - ); - let body = serde_json::json!({ "id": "acp-1" }).to_string(); - let r = handle_acp_agent_connect_request_with_probe(&body, &mut s, |_| { - crate::acp_agent_probe_host::AcpAgentProbeOutcome { - connected: true, - info: Some("Claude Code 1.0".into()), - error: None, - } - }); - - assert!(r.status.starts_with("200"), "{}", r.body); - let parsed: serde_json::Value = serde_json::from_str(&r.body).expect("json body"); - assert_eq!(parsed["connected"], true); - assert_eq!(parsed["connectionInfo"], "Claude Code 1.0"); - - let settings = &s.editor.editor_ui.agent_settings; - assert!(settings.acp_agents[0].connected); - let conn = settings.acp_agent_connection_for("acp-1"); - assert_eq!(conn.phase, AcpAgentConnectPhase::Connected); - assert_eq!(conn.info.as_deref(), Some("Claude Code 1.0")); -} - -#[test] -fn acp_connect_failure_keeps_agent_disconnected() { - use op_editor_core::agent_settings::{AcpAgentConnectPhase, AcpConnectionType}; - use std::collections::BTreeMap; - - let mut s = fresh_state(); - s.editor.editor_ui.agent_settings.add_acp_agent_config( - "Claude Code", - AcpConnectionType::Local, - "claude", - Vec::new(), - BTreeMap::new(), - None, - true, - ); - let body = serde_json::json!({ "id": "acp-1" }).to_string(); - let r = handle_acp_agent_connect_request_with_probe(&body, &mut s, |_| { - crate::acp_agent_probe_host::AcpAgentProbeOutcome { - connected: false, - info: None, - error: Some("failed to spawn ACP agent".into()), - } - }); - - assert!(r.status.starts_with("200"), "{}", r.body); - let settings = &s.editor.editor_ui.agent_settings; - assert!(!settings.acp_agents[0].connected); - let conn = settings.acp_agent_connection_for("acp-1"); - assert_eq!(conn.phase, AcpAgentConnectPhase::Error); - assert_eq!(conn.error.as_deref(), Some("failed to spawn ACP agent")); + let response = serve("POST", path, "{}"); + assert!( + response.contains("404 Not Found"), + "path={path}, {response}" + ); + } } #[test] diff --git a/crates/op-host-services/src/web_chat_standard.rs b/crates/op-host-services/src/web_chat_standard.rs index c1788278f..6d66c82da 100644 --- a/crates/op-host-services/src/web_chat_standard.rs +++ b/crates/op-host-services/src/web_chat_standard.rs @@ -1,9 +1,10 @@ //! Standard chat/design turn for the Rust web shell. //! -//! The browser owns the immediate UI, but it cannot spawn CLI providers or -//! hold API keys. This endpoint mirrors the desktop "standard mode" route on -//! the daemon side: classify the user's turn, then dispatch to plain chat, -//! design modification, or the orchestrator-backed new-design pipeline. +//! The browser owns the immediate UI. This endpoint accepts an optional +//! request-scoped built-in credential and mirrors the desktop "standard mode" +//! route on the daemon side: classify the user's turn, then dispatch to plain +//! chat, design modification, or the orchestrator-backed new-design pipeline. +//! Host CLI and ACP providers are intentionally unavailable on the web route. use std::io::Write; use std::sync::{Arc, Mutex}; @@ -13,7 +14,7 @@ use op_ai::chat_provider::{ ChatAttachment, ChatDelta, ChatHistoryRole, ChatProvider, ChatRequest, StopReason, }; use op_editor_core::chat::MAX_ATTACHMENT_BYTES; -use op_editor_core::{EditorCommand, EditorState, NodeId}; +use op_editor_core::{BuiltinAgentConfig, EditorCommand, EditorState, NodeId}; use op_orchestrator::{ AbortFlag, DesignRequest, DocSink, Orchestrator, Progress, SkippedScreenshotProvider, SkippedVisionLlmClient, ValidationProviders, @@ -36,6 +37,7 @@ pub struct WebStandardTurnRequest { agent_team_size: Option, history: Vec<(ChatHistoryRole, String)>, attachments: Vec, + transient_builtin: Option, } pub fn parse_standard_turn_body(body: &str) -> Option { @@ -65,6 +67,10 @@ pub fn parse_standard_turn_body(body: &str) -> Option { .map(|n| (n as u32).clamp(1, 6)); let history = parse_chat_history(obj.get("history")); let attachments = parse_chat_attachments(obj.get("attachments")); + let transient_builtin = match obj.get("credential") { + None | Some(Value::Null) => None, + Some(value) => Some(crate::web_credentials::parse_transient_builtin(value)?), + }; Some(WebStandardTurnRequest { ai, document_json, @@ -73,6 +79,7 @@ pub fn parse_standard_turn_body(body: &str) -> Option { agent_team_size, history, attachments, + transient_builtin, }) } @@ -171,6 +178,7 @@ pub fn stream_standard_turn( hub.broadcast(version); } } + inject_transient_builtin(&mut snapshot, req.transient_builtin.as_ref()); let Some(classify_provider) = crate::ai_proxy::proxy_provider_with_chat_session(&snapshot, &req.ai.model, false) @@ -218,8 +226,17 @@ fn apply_request_snapshot( hub: &SseHub, ) -> Result { let mut broadcast_version = None; - let snapshot = { + let mut snapshot = { let mut guard = state.lock().unwrap_or_else(|p| p.into_inner()); + if let Some(agent) = req.transient_builtin.as_ref() { + if agent.model.trim() != req.ai.model.trim() { + return Err("transient credential model does not match the request".into()); + } + crate::web_credentials::validate_web_provider_base_url(&agent.base_url)?; + if !crate::web_credentials::public_demo_transient_endpoint_allowed(agent) { + return Err("custom provider endpoint is not explicitly allowed".into()); + } + } if let Some(doc_json) = req.document_json.as_deref() { let loaded = op_pen_loader::load_canonical(doc_json).map_err(|e| e.to_string())?; if guard.editor.doc != loaded.value { @@ -254,9 +271,20 @@ fn apply_request_snapshot( if let Some(version) = broadcast_version { hub.broadcast(version); } + inject_transient_builtin(&mut snapshot, req.transient_builtin.as_ref()); Ok(snapshot) } +fn inject_transient_builtin(state: &mut EditorState, transient: Option<&BuiltinAgentConfig>) { + let Some(transient) = transient else { + return; + }; + let agents = &mut state.editor_ui.agent_settings.builtin_agents; + agents.retain(|agent| agent.id != transient.id); + agents.insert(0, transient.clone()); + state.rebuild_chat_models(); +} + fn selected_model_id(req: &AiStreamRequest) -> Option { let model = req.model.trim(); if model.is_empty() || model == "default" { @@ -669,207 +697,5 @@ fn format_subtask_skills( } #[cfg(test)] -mod tests { - use super::*; - use op_ai::chat_provider::{EffortLevel, ThinkingMode}; - - struct CaptureProvider { - seen: Arc>>, - } - - impl ChatProvider for CaptureProvider { - fn provider_label(&self) -> &str { - "capture" - } - - fn send(&self, request: ChatRequest) -> Box + Send> { - *self.seen.lock().expect("seen lock") = Some(request); - Box::new(std::iter::once(ChatDelta::Done { - stop_reason: StopReason::EndTurn, - })) - } - } - - #[test] - fn parse_standard_turn_body_reads_canvas_snapshot_fields() { - let body = serde_json::json!({ - "model": "claude-sonnet", - "user": "design a page", - "document": { "version": "1.0.0", "children": [] }, - "selectedIds": ["n1", "", "n2"], - "activePageId": "page-1", - "agent_team_size": 9, - "history": [ - { "role": "user", "content": "previous request" }, - { "role": "assistant", "content": "previous answer" }, - { "role": "system", "content": "ignored" }, - { "role": "user", "content": "" } - ], - "attachments": [ - { - "name": "a.png", - "media_type": "image/png", - "data_base64": "AQID" - }, - { - "name": "bad.txt", - "media_type": "text/plain", - "data_base64": "not base64" - } - ] - }) - .to_string(); - - let req = parse_standard_turn_body(&body).expect("request parses"); - - assert_eq!(req.ai.model, "claude-sonnet"); - assert_eq!(req.ai.user, "design a page"); - assert!(req.document_json.is_some()); - assert_eq!(req.selected_ids, vec!["n1".to_string(), "n2".to_string()]); - assert_eq!(req.active_page_id.as_deref(), Some("page-1")); - assert_eq!(req.agent_team_size, Some(6)); - assert_eq!( - req.history, - vec![ - ( - op_ai::chat_provider::ChatHistoryRole::User, - "previous request".into() - ), - ( - op_ai::chat_provider::ChatHistoryRole::Assistant, - "previous answer".into() - ), - ] - ); - assert_eq!(req.attachments.len(), 1); - assert_eq!(req.attachments[0].name, "a.png"); - assert_eq!(req.attachments[0].media_type, "image/png"); - assert_eq!(req.attachments[0].data, vec![1, 2, 3]); - } - - #[test] - fn stream_chat_route_passes_history_and_attachments_to_provider() { - let history = vec![ - (ChatHistoryRole::User, "previous request".to_string()), - (ChatHistoryRole::Assistant, "previous answer".to_string()), - ]; - let attachments = vec![op_ai::chat_provider::ChatAttachment { - name: "a.png".to_string(), - media_type: "image/png".to_string(), - data: vec![1, 2, 3], - }]; - let req = WebStandardTurnRequest { - ai: AiStreamRequest { - model: "claude-sonnet".into(), - skills: Vec::new(), - user: "current request".into(), - max_output_tokens: 2048, - thinking: ThinkingMode::Adaptive, - effort: EffortLevel::Low, - }, - document_json: None, - selected_ids: Vec::new(), - active_page_id: None, - agent_team_size: None, - history: history.clone(), - attachments: attachments.clone(), - }; - let seen = Arc::new(Mutex::new(None)); - let provider = CaptureProvider { seen: seen.clone() }; - let mut out = Vec::new(); - - stream_chat_route(&mut out, &req, &EditorState::new(), &provider, None) - .expect("stream chat"); - - let captured = seen - .lock() - .expect("seen lock") - .clone() - .expect("provider saw request"); - assert_eq!(captured.user_message, "current request"); - assert_eq!(captured.history, history); - assert_eq!(captured.attachments, attachments); - } - - #[test] - fn design_doc_sink_applies_and_bumps_version() { - let state = Mutex::new(WebCanvasState::new(EditorState::new(), 3100)); - let hub = SseHub::default(); - let sub = hub.subscribe(); - let mirror = state.lock().unwrap().editor.clone(); - let mut sink = WebDesignDocSink::new(&state, &hub, mirror); - - assert!(sink.apply(EditorCommand::InsertNode { - kind: "rect".into(), - name: "Generated".into(), - x: 10, - y: 20, - width: 100, - height: 50, - fill_hex: Some("#ff0000".into()), - target_parent: NodeId::NONE, - page_id: None, - })); - - assert_eq!(state.lock().unwrap().version, 1); - assert_eq!(sub.recv().unwrap(), 1); - assert_eq!(sink.state().active_children().len(), 1); - } - - #[test] - fn progress_labels_match_desktop_design_session_bullets() { - assert_eq!(progress_label(&Progress::Planning), "• Planning…"); - assert_eq!( - progress_label(&Progress::SubtaskStarted { - id: "brand".into(), - label: "Brand Header".into(), - }), - "• Subtask `brand` — Brand Header" - ); - } - - // Lock the web progress_label output for SubtaskSkills and SubtaskRetry - // against the cluster-C contract — byte-identical to the desktop formatter - // in op-host-desktop/src/design_session.rs. - #[test] - fn web_progress_label_matches_desktop_skill_block_format() { - use op_orchestrator::SkillBrief; - - let p = Progress::SubtaskSkills { - id: "header".into(), - included: vec![SkillBrief { - name: "cjk-typography".into(), - token_count: 800, - truncated: true, - }], - dropped: vec![("examples".into(), "budget".into())], - budget_used: 5200, - budget_max: 8000, - }; - let s = progress_label(&p); - assert!( - s.contains("• Subtask `header` · 1 skills · 5200/8000 tok · 1 dropped"), - "summary line mismatch: {s}" - ); - assert!( - s.contains("\n ▸ skills: cjk-typography (truncated)"), - "skills sub-line mismatch: {s}" - ); - assert!( - s.contains("\n ▸ dropped: examples (budget)"), - "dropped sub-line mismatch: {s}" - ); - } - - #[test] - fn web_progress_label_subtask_retry_format() { - assert_eq!( - progress_label(&Progress::SubtaskRetry { - id: "header".into(), - attempt: 2, - reason: "zero nodes generated".into(), - }), - " ▸ retry #2: zero nodes generated" - ); - } -} +#[path = "web_chat_standard_tests.rs"] +mod tests; diff --git a/crates/op-host-services/src/web_chat_standard_tests.rs b/crates/op-host-services/src/web_chat_standard_tests.rs new file mode 100644 index 000000000..d637f131a --- /dev/null +++ b/crates/op-host-services/src/web_chat_standard_tests.rs @@ -0,0 +1,317 @@ +use super::*; +use op_ai::chat_provider::{EffortLevel, ThinkingMode}; + +struct CaptureProvider { + seen: Arc>>, +} + +impl ChatProvider for CaptureProvider { + fn provider_label(&self) -> &str { + "capture" + } + + fn send(&self, request: ChatRequest) -> Box + Send> { + *self.seen.lock().expect("seen lock") = Some(request); + Box::new(std::iter::once(ChatDelta::Done { + stop_reason: StopReason::EndTurn, + })) + } +} + +fn standard_body_with_credential(api_key: &str) -> String { + standard_body_with_credential_at(api_key, "https://api.openai.com/v1") +} + +fn standard_body_with_credential_at(api_key: &str, base_url: &str) -> String { + serde_json::json!({ + "model": "private-model", + "user": "hello", + "credential": { + "id": "builtin-web-1", + "preset": "custom", + "display_name": "Private", + "kind": "openai-compat", + "api_key": api_key, + "model": "private-model", + "base_url": base_url, + "enabled": true + } + }) + .to_string() +} + +#[test] +fn transient_credential_is_applied_only_to_the_turn_snapshot() { + let body = standard_body_with_credential("sk-transient"); + let req = parse_standard_turn_body(&body).expect("request parses"); + let state = Mutex::new(WebCanvasState::new(EditorState::new(), 3100)); + + let snapshot = + apply_request_snapshot(&req, &state, &SseHub::default()).expect("request snapshot"); + + assert_eq!( + snapshot.editor_ui.agent_settings.builtin_agents[0].api_key, + "sk-transient" + ); + assert!(crate::ai_proxy::proxy_provider(&snapshot, "private-model").is_some()); + assert!(state + .lock() + .unwrap() + .editor + .editor_ui + .agent_settings + .builtin_agents + .is_empty()); +} + +#[test] +fn invalid_transient_credential_is_rejected() { + let oversized = "x".repeat(16 * 1024 + 1); + let body = standard_body_with_credential(&oversized); + + assert!(parse_standard_turn_body(&body).is_none()); +} + +#[test] +fn transient_credential_model_must_match_the_requested_model() { + let mut body: serde_json::Value = + serde_json::from_str(&standard_body_with_credential("sk-transient")).unwrap(); + body["model"] = serde_json::Value::String("different-model".into()); + let req = parse_standard_turn_body(&body.to_string()).expect("credential shape parses"); + let state = Mutex::new(WebCanvasState::new(EditorState::new(), 3100)); + + let error = apply_request_snapshot(&req, &state, &SseHub::default()) + .expect_err("mismatched credential must be rejected"); + + assert!(error.contains("model does not match")); + assert!(state + .lock() + .unwrap() + .editor + .editor_ui + .agent_settings + .builtin_agents + .is_empty()); +} + +#[test] +fn browser_only_demo_rejects_custom_or_loopback_transient_endpoints_without_mutation() { + for base_url in ["http://127.0.0.1:8080/v1", "https://example.test/v1"] { + let body = standard_body_with_credential_at("sk-transient", base_url); + let req = parse_standard_turn_body(&body).expect("credential shape parses"); + let state = Mutex::new(WebCanvasState::new_with_policy( + EditorState::new(), + 3100, + crate::web_credential_policy::WebCredentialPersistence::BrowserOnly, + )); + let before = crate::settings_io::fingerprint(&state.lock().unwrap().editor); + + let error = apply_request_snapshot(&req, &state, &SseHub::default()) + .expect_err("public demo must reject custom endpoint"); + + assert!(error.contains("endpoint")); + assert_eq!( + before, + crate::settings_io::fingerprint(&state.lock().unwrap().editor) + ); + } +} + +#[test] +fn server_persistence_does_not_allow_an_unapproved_public_transient_endpoint() { + let body = standard_body_with_credential_at("sk-transient", "https://attacker.example/v1"); + let req = parse_standard_turn_body(&body).expect("credential shape parses"); + let state = Mutex::new(WebCanvasState::new_with_policy( + EditorState::new(), + 3100, + crate::web_credential_policy::WebCredentialPersistence::Server, + )); + + let error = apply_request_snapshot(&req, &state, &SseHub::default()) + .expect_err("persistence must not authorize an arbitrary public provider endpoint"); + assert!(error.contains("endpoint"), "unexpected error: {error}"); +} + +#[test] +fn parse_standard_turn_body_reads_canvas_snapshot_fields() { + let body = serde_json::json!({ + "model": "claude-sonnet", + "user": "design a page", + "document": { "version": "1.0.0", "children": [] }, + "selectedIds": ["n1", "", "n2"], + "activePageId": "page-1", + "agent_team_size": 9, + "history": [ + { "role": "user", "content": "previous request" }, + { "role": "assistant", "content": "previous answer" }, + { "role": "system", "content": "ignored" }, + { "role": "user", "content": "" } + ], + "attachments": [ + { + "name": "a.png", + "media_type": "image/png", + "data_base64": "AQID" + }, + { + "name": "bad.txt", + "media_type": "text/plain", + "data_base64": "not base64" + } + ] + }) + .to_string(); + + let req = parse_standard_turn_body(&body).expect("request parses"); + + assert_eq!(req.ai.model, "claude-sonnet"); + assert_eq!(req.ai.user, "design a page"); + assert!(req.document_json.is_some()); + assert_eq!(req.selected_ids, vec!["n1".to_string(), "n2".to_string()]); + assert_eq!(req.active_page_id.as_deref(), Some("page-1")); + assert_eq!(req.agent_team_size, Some(6)); + assert_eq!( + req.history, + vec![ + ( + op_ai::chat_provider::ChatHistoryRole::User, + "previous request".into() + ), + ( + op_ai::chat_provider::ChatHistoryRole::Assistant, + "previous answer".into() + ), + ] + ); + assert_eq!(req.attachments.len(), 1); + assert_eq!(req.attachments[0].name, "a.png"); + assert_eq!(req.attachments[0].media_type, "image/png"); + assert_eq!(req.attachments[0].data, vec![1, 2, 3]); +} + +#[test] +fn stream_chat_route_passes_history_and_attachments_to_provider() { + let history = vec![ + (ChatHistoryRole::User, "previous request".to_string()), + (ChatHistoryRole::Assistant, "previous answer".to_string()), + ]; + let attachments = vec![op_ai::chat_provider::ChatAttachment { + name: "a.png".to_string(), + media_type: "image/png".to_string(), + data: vec![1, 2, 3], + }]; + let req = WebStandardTurnRequest { + ai: AiStreamRequest { + model: "claude-sonnet".into(), + skills: Vec::new(), + user: "current request".into(), + max_output_tokens: 2048, + thinking: ThinkingMode::Adaptive, + effort: EffortLevel::Low, + transient_builtin: None, + }, + document_json: None, + selected_ids: Vec::new(), + active_page_id: None, + agent_team_size: None, + history: history.clone(), + attachments: attachments.clone(), + transient_builtin: None, + }; + let seen = Arc::new(Mutex::new(None)); + let provider = CaptureProvider { seen: seen.clone() }; + let mut out = Vec::new(); + + stream_chat_route(&mut out, &req, &EditorState::new(), &provider, None).expect("stream chat"); + + let captured = seen + .lock() + .expect("seen lock") + .clone() + .expect("provider saw request"); + assert_eq!(captured.user_message, "current request"); + assert_eq!(captured.history, history); + assert_eq!(captured.attachments, attachments); +} + +#[test] +fn design_doc_sink_applies_and_bumps_version() { + let state = Mutex::new(WebCanvasState::new(EditorState::new(), 3100)); + let hub = SseHub::default(); + let sub = hub.subscribe(); + let mirror = state.lock().unwrap().editor.clone(); + let mut sink = WebDesignDocSink::new(&state, &hub, mirror); + + assert!(sink.apply(EditorCommand::InsertNode { + kind: "rect".into(), + name: "Generated".into(), + x: 10, + y: 20, + width: 100, + height: 50, + fill_hex: Some("#ff0000".into()), + target_parent: NodeId::NONE, + page_id: None, + })); + + assert_eq!(state.lock().unwrap().version, 1); + assert_eq!(sub.recv().unwrap(), 1); + assert_eq!(sink.state().active_children().len(), 1); +} + +#[test] +fn progress_labels_match_desktop_design_session_bullets() { + assert_eq!(progress_label(&Progress::Planning), "• Planning…"); + assert_eq!( + progress_label(&Progress::SubtaskStarted { + id: "brand".into(), + label: "Brand Header".into(), + }), + "• Subtask `brand` — Brand Header" + ); +} + +// Lock the web progress_label output for SubtaskSkills and SubtaskRetry +// against the cluster-C contract — byte-identical to the desktop formatter +// in op-host-desktop/src/design_session.rs. +#[test] +fn web_progress_label_matches_desktop_skill_block_format() { + use op_orchestrator::SkillBrief; + + let p = Progress::SubtaskSkills { + id: "header".into(), + included: vec![SkillBrief { + name: "cjk-typography".into(), + token_count: 800, + truncated: true, + }], + dropped: vec![("examples".into(), "budget".into())], + budget_used: 5200, + budget_max: 8000, + }; + let s = progress_label(&p); + assert!( + s.contains("• Subtask `header` · 1 skills · 5200/8000 tok · 1 dropped"), + "summary line mismatch: {s}" + ); + assert!( + s.contains("\n ▸ skills: cjk-typography (truncated)"), + "skills sub-line mismatch: {s}" + ); + assert!( + s.contains("\n ▸ dropped: examples (budget)"), + "dropped sub-line mismatch: {s}" + ); +} + +#[test] +fn web_progress_label_subtask_retry_format() { + assert_eq!( + progress_label(&Progress::SubtaskRetry { + id: "header".into(), + attempt: 2, + reason: "zero nodes generated".into(), + }), + " ▸ retry #2: zero nodes generated" + ); +} diff --git a/crates/op-host-services/src/web_credential_policy.rs b/crates/op-host-services/src/web_credential_policy.rs new file mode 100644 index 000000000..a562481df --- /dev/null +++ b/crates/op-host-services/src/web_credential_policy.rs @@ -0,0 +1,63 @@ +/// Deployment switch for allowing browser credentials to be copied into the +/// daemon's platform settings file. +pub const ENV_NAME: &str = "OPENPENCIL_PERSIST_WEB_CREDENTIALS_SERVER"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebCredentialPersistence { + BrowserOnly, + Server, +} + +impl WebCredentialPersistence { + pub fn server_persistence(self) -> bool { + matches!(self, Self::Server) + } +} + +pub fn parse(raw: Option<&str>) -> WebCredentialPersistence { + match raw.map(str::trim).filter(|value| !value.is_empty()) { + Some(value) if value.eq_ignore_ascii_case("true") => WebCredentialPersistence::Server, + _ => WebCredentialPersistence::BrowserOnly, + } +} + +pub fn from_env() -> WebCredentialPersistence { + let raw = std::env::var(ENV_NAME).ok(); + let policy = parse(raw.as_deref()); + if raw.as_deref().is_some_and(|value| { + let value = value.trim(); + !value.is_empty() + && !["0", "1", "false", "true", "no", "yes", "off", "on"] + .iter() + .any(|known| value.eq_ignore_ascii_case(known)) + }) { + eprintln!("openpencil --serve-web: invalid {ENV_NAME}; using browser-only credentials"); + } + policy +} + +#[cfg(test)] +mod tests { + use super::{parse, WebCredentialPersistence}; + + #[test] + fn missing_or_invalid_policy_fails_closed() { + assert_eq!(parse(None), WebCredentialPersistence::BrowserOnly); + assert_eq!(parse(Some("")), WebCredentialPersistence::BrowserOnly); + assert_eq!(parse(Some("maybe")), WebCredentialPersistence::BrowserOnly); + } + + #[test] + fn only_trimmed_case_insensitive_true_enables_server_persistence() { + for raw in ["true", "TRUE", " TrUe ", "\ttrue\n"] { + assert_eq!(parse(Some(raw)), WebCredentialPersistence::Server); + } + } + + #[test] + fn truthy_aliases_do_not_enable_server_persistence() { + for raw in ["1", "yes", "on", "enabled"] { + assert_eq!(parse(Some(raw)), WebCredentialPersistence::BrowserOnly); + } + } +} diff --git a/crates/op-host-services/src/web_credentials.rs b/crates/op-host-services/src/web_credentials.rs new file mode 100644 index 000000000..04a15c039 --- /dev/null +++ b/crates/op-host-services/src/web_credentials.rs @@ -0,0 +1,577 @@ +use std::collections::HashSet; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + +use op_editor_core::{ + AgentSettings, BuiltinAgentConfig, BuiltinAgentKind, BuiltinAgentPresetKey, EditorState, + ImageGenProfile, ImageGenProvider, ImageTestStatus, +}; +use serde::Deserialize; + +pub const MAX_CREDENTIAL_BODY_BYTES: usize = 256 * 1024; + +const MAX_TEXT_BYTES: usize = 512; +const MAX_URL_BYTES: usize = 4 * 1024; +const MAX_CREDENTIAL_BYTES: usize = 16 * 1024; +const MAX_LOCAL_ID_BYTES: usize = 128; +const MAX_ENTRIES: usize = 64; +const MAX_TOTAL_ENTRIES: usize = 256; +const PAYLOAD_VERSION: u32 = 2; +const WEB_CREDENTIAL_PREFIX: &str = "web-credential:"; +const WEB_CREDENTIAL_OWNER: &str = "browser"; +pub const WEB_AI_ENDPOINT_ALLOWLIST_ENV: &str = "OPENPENCIL_WEB_AI_ENDPOINT_ALLOWLIST"; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct CredentialPayload { + version: u32, + #[serde(default)] + builtin_agents: Vec, + #[serde(default)] + image_gen_profiles: Vec, + active_image_gen_profile_id: Option, + openverse_oauth: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct BuiltinAgentPayload { + id: String, + #[serde(default)] + preset: Option, + display_name: String, + kind: String, + #[serde(default)] + api_key: String, + model: String, + base_url: String, + enabled: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ImageGenProfilePayload { + id: String, + name: String, + provider: String, + #[serde(default)] + api_key: String, + model: String, + #[serde(default)] + base_url: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct OpenverseOAuthPayload { + client_id: String, + client_secret: String, +} + +/// Validate and atomically merge a browser credential snapshot into daemon +/// settings. Unrelated daemon-managed entries remain intact. +pub fn apply_json(state: &mut EditorState, body: &str) -> Result<(), String> { + if body.len() > MAX_CREDENTIAL_BODY_BYTES { + return Err("credential payload exceeds 256 KiB".into()); + } + let payload: CredentialPayload = + serde_json::from_str(body).map_err(|_| "invalid credential payload".to_string())?; + if payload.version != PAYLOAD_VERSION { + return Err("unsupported credential payload version".into()); + } + if payload.builtin_agents.len() > MAX_ENTRIES || payload.image_gen_profiles.len() > MAX_ENTRIES + { + return Err("credential payload has too many entries".into()); + } + let mut next = state.editor_ui.agent_settings.clone(); + validate_and_merge(&mut next, payload)?; + state.editor_ui.agent_settings = next; + state.rebuild_chat_models(); + Ok(()) +} + +pub(crate) fn parse_transient_builtin(value: &serde_json::Value) -> Option { + let payload: BuiltinAgentPayload = serde_json::from_value(value.clone()).ok()?; + validate_required_id(&payload.id, "built-in agent id").ok()?; + let agent = parse_builtin_agent(payload).ok()?; + if !agent.enabled + || agent.api_key.trim().is_empty() + || agent.model.trim().is_empty() + || agent.base_url.trim().is_empty() + { + return None; + } + Some(agent) +} + +pub(crate) fn public_demo_transient_endpoint_allowed(agent: &BuiltinAgentConfig) -> bool { + let allowlist = std::env::var(WEB_AI_ENDPOINT_ALLOWLIST_ENV).ok(); + let Ok(candidate) = + validate_web_provider_base_url_with_allowlist(agent.base_url.trim(), allowlist.as_deref()) + else { + return false; + }; + if endpoint_is_explicitly_allowlisted(&candidate, allowlist.as_deref()) { + return true; + } + op_editor_core::BUILTIN_AGENT_PRESETS.iter().any(|preset| { + std::iter::once(Some(preset.base_url)) + .chain(std::iter::once(preset.alt_base_url)) + .flatten() + .filter(|base_url| !base_url.is_empty()) + .any(|base_url| provider_endpoint_matches(&candidate, base_url)) + }) +} + +/// Validate a browser-controlled provider endpoint independently of whether +/// browser credentials are persisted. Web AI request paths repeat this check, +/// so enabling server persistence never doubles as an SSRF opt-in. +pub(crate) fn validate_web_provider_base_url(base_url: &str) -> Result { + let allowlist = std::env::var(WEB_AI_ENDPOINT_ALLOWLIST_ENV).ok(); + validate_web_provider_base_url_with_allowlist(base_url, allowlist.as_deref()) +} + +fn validate_web_provider_base_url_with_allowlist( + base_url: &str, + allowlist: Option<&str>, +) -> Result { + let url = reqwest::Url::parse(base_url.trim()) + .map_err(|_| "browser provider endpoint is invalid".to_string())?; + if !matches!(url.scheme(), "http" | "https") + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err("browser provider endpoint is not allowed".into()); + } + if endpoint_is_explicitly_allowlisted(&url, allowlist) { + return Ok(url); + } + if url.scheme() != "https" { + return Err("browser provider endpoint must use HTTPS".into()); + } + + let host = url + .host_str() + .expect("host presence checked above") + .trim_end_matches('.') + .to_ascii_lowercase(); + if host.parse::().is_ok_and(is_restricted_ip) || is_restricted_hostname(&host) { + return Err("browser provider endpoint is not allowed".into()); + } + Ok(url) +} + +fn endpoint_is_explicitly_allowlisted(url: &reqwest::Url, allowlist: Option<&str>) -> bool { + allowlist + .into_iter() + .flat_map(|value| value.split(',')) + .any(|entry| { + let Ok(allowed) = reqwest::Url::parse(entry.trim()) else { + return false; + }; + matches!(allowed.scheme(), "http" | "https") + && allowed.host_str().is_some() + && allowed.username().is_empty() + && allowed.password().is_none() + && allowed.query().is_none() + && allowed.fragment().is_none() + && url.scheme() == allowed.scheme() + && url + .host_str() + .zip(allowed.host_str()) + .is_some_and(|(left, right)| left.eq_ignore_ascii_case(right)) + && url.port_or_known_default() == allowed.port_or_known_default() + }) +} + +fn is_restricted_hostname(host: &str) -> bool { + host.is_empty() + || !host.contains('.') + || [ + "localhost", + ".localhost", + ".local", + ".internal", + ".home", + ".lan", + ".test", + ".invalid", + ] + .iter() + .any(|suffix| host == *suffix || host.ends_with(suffix)) + || matches!( + host, + "metadata.google.internal" + | "metadata.google" + | "instance-data" + | "instance-data.ec2.internal" + ) +} + +fn is_restricted_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(ip) => is_restricted_ipv4(ip), + IpAddr::V6(ip) => is_restricted_ipv6(ip), + } +} + +fn is_restricted_ipv4(ip: Ipv4Addr) -> bool { + let [a, b, c, d] = ip.octets(); + a == 0 + || a == 10 + || (a == 100 && (64..=127).contains(&b)) + || a == 127 + || (a == 169 && b == 254) + || (a == 172 && (16..=31).contains(&b)) + || (a == 192 && b == 0 && c == 0) + || (a == 192 && b == 0 && c == 2) + || (a == 192 && b == 88 && c == 99) + || (a == 192 && b == 168) + || (a == 198 && matches!(b, 18 | 19)) + || (a == 198 && b == 51 && c == 100) + || (a == 203 && b == 0 && c == 113) + || a >= 224 + || [a, b, c, d] == [168, 63, 129, 16] +} + +fn is_restricted_ipv6(ip: Ipv6Addr) -> bool { + let segments = ip.segments(); + if segments[..5] == [0, 0, 0, 0, 0] && segments[5] == 0xffff { + let [a, b] = segments[6].to_be_bytes(); + let [c, d] = segments[7].to_be_bytes(); + return is_restricted_ipv4(Ipv4Addr::new(a, b, c, d)); + } + (segments[0] & 0xe000) != 0x2000 + || segments[0] == 0x2002 + || segments[0] == 0x3fff + || (segments[0] == 0x2001 + && (matches!(segments[1], 0 | 2 | 0x0db8) + || (segments[1] & 0xfff0) == 0x0010 + || (segments[1] & 0xfff0) == 0x0020)) +} + +pub(crate) fn browser_owns_builtin_agent(agent: &BuiltinAgentConfig) -> bool { + agent.id.starts_with(&owner_prefix("builtin")) +} + +pub(crate) fn remove_browser_owned_credentials(state: &mut EditorState) -> bool { + let builtin_prefix = owner_prefix("builtin"); + let image_prefix = owner_prefix("image"); + let settings = &mut state.editor_ui.agent_settings; + let had_browser_credentials = settings + .builtin_agents + .iter() + .any(|agent| agent.id.starts_with(&builtin_prefix)) + || settings + .image_gen_profiles + .iter() + .any(|profile| profile.id.starts_with(&image_prefix)) + || settings.openverse_credential_owner.as_deref() == Some(WEB_CREDENTIAL_OWNER); + settings + .builtin_agents + .retain(|agent| !agent.id.starts_with(&builtin_prefix)); + settings + .image_gen_profiles + .retain(|profile| !profile.id.starts_with(&image_prefix)); + if settings + .active_image_gen_profile_id + .as_deref() + .is_some_and(|id| id.starts_with(&image_prefix)) + { + settings.active_image_gen_profile_id = settings + .image_gen_profiles + .first() + .map(|profile| profile.id.clone()); + } + if settings.openverse_credential_owner.as_deref() == Some(WEB_CREDENTIAL_OWNER) { + settings.openverse_client_id.clear(); + settings.openverse_client_secret.clear(); + settings.openverse_credential_owner = None; + } + settings.next_builtin_agent_id = next_numeric_id( + settings + .builtin_agents + .iter() + .map(|agent| agent.id.as_str()), + "builtin-", + ); + settings.next_image_gen_profile_id = next_numeric_id( + settings + .image_gen_profiles + .iter() + .map(|profile| profile.id.as_str()), + "igp-", + ); + state.rebuild_chat_models(); + had_browser_credentials +} + +fn provider_endpoint_matches(candidate: &reqwest::Url, allowed: &str) -> bool { + let Ok(allowed) = reqwest::Url::parse(allowed) else { + return false; + }; + candidate.scheme() == allowed.scheme() + && candidate + .host_str() + .zip(allowed.host_str()) + .is_some_and(|(left, right)| left.eq_ignore_ascii_case(right)) + && candidate.port_or_known_default() == allowed.port_or_known_default() + && candidate.path().trim_end_matches('/') == allowed.path().trim_end_matches('/') +} + +fn validate_and_merge( + settings: &mut AgentSettings, + payload: CredentialPayload, +) -> Result<(), String> { + let builtin_prefix = owner_prefix("builtin"); + let image_prefix = owner_prefix("image"); + + let mut builtin_ids = HashSet::with_capacity(payload.builtin_agents.len()); + let mut builtins = Vec::with_capacity(payload.builtin_agents.len()); + for payload in payload.builtin_agents { + validate_local_id(&payload.id, "built-in agent id")?; + let local_id = payload.id.clone(); + if !builtin_ids.insert(local_id.clone()) { + return Err("credential payload contains duplicate built-in agent ids".into()); + } + let mut agent = parse_builtin_agent(payload)?; + validate_web_provider_base_url(&agent.base_url)?; + if !public_demo_transient_endpoint_allowed(&agent) { + return Err("browser provider endpoint is not explicitly allowed".into()); + } + agent.id = scoped_id(&builtin_prefix, &local_id); + builtins.push(agent); + } + + let mut image_ids = HashSet::with_capacity(payload.image_gen_profiles.len()); + let mut image_profiles = Vec::with_capacity(payload.image_gen_profiles.len()); + for payload in payload.image_gen_profiles { + validate_local_id(&payload.id, "image profile id")?; + let local_id = payload.id.clone(); + if !image_ids.insert(local_id.clone()) { + return Err("credential payload contains duplicate image profile ids".into()); + } + let mut profile = parse_image_profile(payload)?; + if let Some(base_url) = profile + .base_url + .as_deref() + .filter(|base_url| !base_url.trim().is_empty()) + { + validate_web_provider_base_url(base_url)?; + } + profile.id = scoped_id(&image_prefix, &local_id); + image_profiles.push(profile); + } + + let active_image_gen_profile_id = payload + .active_image_gen_profile_id + .as_deref() + .map(|id| { + validate_local_id(id, "active image profile id")?; + if !image_ids.contains(id) { + return Err("active image profile is not in the browser snapshot".to_string()); + } + Ok(scoped_id(&image_prefix, id)) + }) + .transpose()?; + if let Some(oauth) = payload.openverse_oauth.as_ref() { + validate_len( + &oauth.client_id, + MAX_CREDENTIAL_BYTES, + "Openverse client id", + )?; + validate_len( + &oauth.client_secret, + MAX_CREDENTIAL_BYTES, + "Openverse client secret", + )?; + if oauth.client_id.trim().is_empty() && oauth.client_secret.trim().is_empty() { + return Err("Openverse credentials must not both be empty".into()); + } + } + + let browser_can_manage_openverse = settings.openverse_credential_owner.as_deref() + == Some(WEB_CREDENTIAL_OWNER) + || (settings.openverse_credential_owner.is_none() + && settings.openverse_client_id.trim().is_empty() + && settings.openverse_client_secret.trim().is_empty()); + + settings + .builtin_agents + .retain(|agent| !agent.id.starts_with(&builtin_prefix)); + settings + .image_gen_profiles + .retain(|profile| !profile.id.starts_with(&image_prefix)); + settings.builtin_agents.extend(builtins); + settings.image_gen_profiles.extend(image_profiles); + if settings.builtin_agents.len() > MAX_TOTAL_ENTRIES + || settings.image_gen_profiles.len() > MAX_TOTAL_ENTRIES + { + return Err("credential store has too many entries".into()); + } + + if let Some(id) = active_image_gen_profile_id { + settings.active_image_gen_profile_id = Some(id); + } else if settings + .active_image_gen_profile_id + .as_deref() + .is_some_and(|id| id.starts_with(&image_prefix)) + { + settings.active_image_gen_profile_id = settings + .image_gen_profiles + .first() + .map(|profile| profile.id.clone()); + } + if browser_can_manage_openverse { + if let Some(oauth) = payload.openverse_oauth { + settings.openverse_client_id = oauth.client_id; + settings.openverse_client_secret = oauth.client_secret; + settings.openverse_credential_owner = Some(WEB_CREDENTIAL_OWNER.into()); + } else if settings.openverse_credential_owner.as_deref() == Some(WEB_CREDENTIAL_OWNER) { + settings.openverse_client_id.clear(); + settings.openverse_client_secret.clear(); + settings.openverse_credential_owner = None; + } + } + + settings.next_builtin_agent_id = next_numeric_id( + settings + .builtin_agents + .iter() + .map(|agent| agent.id.as_str()), + "builtin-", + ); + settings.next_image_gen_profile_id = next_numeric_id( + settings + .image_gen_profiles + .iter() + .map(|profile| profile.id.as_str()), + "igp-", + ); + Ok(()) +} + +fn parse_builtin_agent(payload: BuiltinAgentPayload) -> Result { + validate_len( + &payload.display_name, + MAX_TEXT_BYTES, + "built-in display name", + )?; + validate_len(&payload.model, MAX_TEXT_BYTES, "built-in model")?; + validate_len(&payload.base_url, MAX_URL_BYTES, "built-in base URL")?; + validate_len(&payload.api_key, MAX_CREDENTIAL_BYTES, "built-in API key")?; + if let Some(preset) = payload.preset.as_deref() { + validate_len(preset, MAX_TEXT_BYTES, "built-in preset")?; + } + + let kind = match payload.kind.as_str() { + "anthropic" => BuiltinAgentKind::Anthropic, + "openai" | "openai-compat" | "openai_compat" => BuiltinAgentKind::OpenAiCompat, + _ => return Err("unsupported built-in agent kind".into()), + }; + let preset = payload + .preset + .as_deref() + .and_then(BuiltinAgentPresetKey::from_str) + .map(|saved| { + op_editor_core::normalize_builtin_agent_preset( + saved, + kind, + &payload.base_url, + &payload.model, + ) + }) + .unwrap_or_else(|| { + op_editor_core::infer_builtin_agent_preset(kind, &payload.base_url, &payload.model) + }); + + Ok(BuiltinAgentConfig { + id: payload.id, + preset, + display_name: payload.display_name, + kind, + api_key: payload.api_key, + model: payload.model, + base_url: payload.base_url, + enabled: payload.enabled, + }) +} + +fn parse_image_profile(payload: ImageGenProfilePayload) -> Result { + validate_len(&payload.name, MAX_TEXT_BYTES, "image profile name")?; + validate_len(&payload.model, MAX_TEXT_BYTES, "image profile model")?; + validate_len( + &payload.api_key, + MAX_CREDENTIAL_BYTES, + "image profile API key", + )?; + if let Some(base_url) = payload.base_url.as_deref() { + validate_len(base_url, MAX_URL_BYTES, "image profile base URL")?; + } + + let provider = match payload.provider.as_str() { + "openai" => ImageGenProvider::OpenAi, + "gemini" => ImageGenProvider::Gemini, + "replicate" => ImageGenProvider::Replicate, + "custom" => ImageGenProvider::Custom, + _ => return Err("unsupported image generation provider".into()), + }; + + Ok(ImageGenProfile { + id: payload.id, + name: payload.name, + provider, + api_key: payload.api_key, + model: payload.model, + base_url: payload.base_url, + test_status: ImageTestStatus::Idle, + }) +} + +fn validate_required_id(value: &str, field: &str) -> Result<(), String> { + validate_len(value, MAX_TEXT_BYTES, field)?; + if value.trim().is_empty() { + return Err(format!("{field} must not be empty")); + } + Ok(()) +} + +fn validate_local_id(value: &str, field: &str) -> Result<(), String> { + if value.is_empty() + || value.len() > MAX_LOCAL_ID_BYTES + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + { + return Err(format!("{field} is invalid")); + } + Ok(()) +} + +fn owner_prefix(category: &str) -> String { + format!("{WEB_CREDENTIAL_PREFIX}{category}:") +} + +fn scoped_id(prefix: &str, local_id: &str) -> String { + format!("{prefix}{local_id}") +} + +fn validate_len(value: &str, max: usize, field: &str) -> Result<(), String> { + if value.len() > max { + return Err(format!("{field} is too long")); + } + Ok(()) +} + +fn next_numeric_id<'a>(ids: impl Iterator, prefix: &str) -> u64 { + ids.filter_map(|id| id.strip_prefix(prefix)?.parse::().ok()) + .max() + .unwrap_or(0) + .saturating_add(1) +} + +#[cfg(test)] +#[path = "web_credentials_tests.rs"] +mod tests; diff --git a/crates/op-host-services/src/web_credentials_tests.rs b/crates/op-host-services/src/web_credentials_tests.rs new file mode 100644 index 000000000..283870316 --- /dev/null +++ b/crates/op-host-services/src/web_credentials_tests.rs @@ -0,0 +1,355 @@ +use super::{apply_json, validate_web_provider_base_url_with_allowlist, MAX_CREDENTIAL_BODY_BYTES}; +use op_editor_core::{BuiltinAgentConfig, BuiltinAgentKind, BuiltinAgentPresetKey, EditorState}; + +struct EnvVarGuard { + key: &'static str, + previous: Option, +} + +impl EnvVarGuard { + fn unset(key: &'static str) -> Self { + let previous = std::env::var_os(key); + std::env::remove_var(key); + Self { key, previous } + } +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + match &self.previous { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } +} + +const VALID_BODY: &str = r#"{ + "version":2, + "builtin_agents":[{ + "id":"builtin-web-1","preset":"custom","display_name":"Private Model", + "kind":"openai-compat","api_key":"sk-browser-only","model":"private-model", + "base_url":"https://api.openai.com/v1","enabled":true + }], + "image_gen_profiles":[{ + "id":"igp-web-1","name":"Image","provider":"openai", + "api_key":"image-browser-only","model":"gpt-image-1","base_url":null + }], + "active_image_gen_profile_id":"igp-web-1", + "openverse_oauth":{"client_id":"client","client_secret":"openverse-browser-only"} +}"#; + +fn state_with_operator_agent() -> EditorState { + let mut state = EditorState::new(); + state + .editor_ui + .agent_settings + .builtin_agents + .push(BuiltinAgentConfig { + id: "operator-agent".into(), + preset: BuiltinAgentPresetKey::Custom, + display_name: "Operator".into(), + kind: BuiltinAgentKind::OpenAiCompat, + api_key: "operator-secret".into(), + model: "operator-model".into(), + base_url: "https://operator.example/v1".into(), + enabled: true, + }); + state +} + +#[test] +fn merge_preserves_unrelated_operator_credentials() { + let mut state = state_with_operator_agent(); + + apply_json(&mut state, VALID_BODY).expect("valid credential merge"); + + let settings = &state.editor_ui.agent_settings; + assert!(settings + .builtin_agents + .iter() + .any(|agent| agent.id == "operator-agent" && agent.api_key == "operator-secret")); + assert!(settings + .builtin_agents + .iter() + .any(|agent| agent.id.ends_with(":builtin:builtin-web-1") + && agent.api_key == "sk-browser-only")); + assert!(settings + .image_gen_profiles + .iter() + .any(|profile| profile.id.ends_with(":image:igp-web-1") + && profile.api_key == "image-browser-only")); + assert_eq!( + settings + .image_gen_profiles + .iter() + .filter(|profile| profile.id.starts_with("web-credential:image:")) + .count(), + 1 + ); + assert_eq!(settings.openverse_client_secret, "openverse-browser-only"); + assert_eq!( + settings.openverse_credential_owner.as_deref(), + Some("browser") + ); +} + +#[test] +fn credential_merge_keeps_document_storage_in_place() { + let mut state = EditorState::starter(); + let document_children = state.doc.children.as_ptr(); + + apply_json(&mut state, VALID_BODY).expect("valid credential merge"); + + assert_eq!(state.doc.children.as_ptr(), document_children); +} + +#[test] +fn realistic_id_collisions_do_not_overwrite_operator_credentials() { + let mut state = EditorState::new(); + state.editor_ui.agent_settings.add_builtin_agent_config( + "Operator", + "operator-secret", + "operator-model", + BuiltinAgentKind::OpenAiCompat, + "https://operator.example/v1", + ); + state.editor_ui.agent_settings.add_image_gen_profile(); + state.editor_ui.agent_settings.image_gen_profiles[0].api_key = "operator-image".into(); + let body = VALID_BODY + .replace("builtin-web-1", "builtin-1") + .replace("igp-web-1", "igp-1"); + + apply_json(&mut state, &body).expect("browser snapshot merges"); + + let settings = &state.editor_ui.agent_settings; + assert!(settings + .builtin_agents + .iter() + .any(|agent| agent.id == "builtin-1" && agent.api_key == "operator-secret")); + assert!(settings + .builtin_agents + .iter() + .any(|agent| agent.id != "builtin-1" && agent.api_key == "sk-browser-only")); + assert!(settings + .image_gen_profiles + .iter() + .any(|profile| profile.id == "igp-1" && profile.api_key == "operator-image")); + assert!(settings + .image_gen_profiles + .iter() + .any(|profile| profile.id != "igp-1" && profile.api_key == "image-browser-only")); +} + +#[test] +fn browser_snapshot_deletes_only_browser_owned_missing_entries() { + let mut state = state_with_operator_agent(); + apply_json(&mut state, VALID_BODY).expect("initial browser snapshot"); + let empty = r#"{ + "version":2, + "builtin_agents":[], + "image_gen_profiles":[], + "active_image_gen_profile_id":null, + "openverse_oauth":null + }"#; + + apply_json(&mut state, empty).expect("empty browser snapshot"); + + let settings = &state.editor_ui.agent_settings; + assert_eq!(settings.builtin_agents.len(), 1); + assert_eq!(settings.builtin_agents[0].id, "operator-agent"); + assert!(settings.image_gen_profiles.is_empty()); + assert!(settings.openverse_client_secret.is_empty()); +} + +#[test] +fn operator_openverse_credentials_win_without_blocking_other_browser_credentials() { + let mut state = EditorState::new(); + state.editor_ui.agent_settings.openverse_client_id = "operator-client".into(); + state.editor_ui.agent_settings.openverse_client_secret = "operator-secret".into(); + + apply_json(&mut state, VALID_BODY).expect("non-Openverse credentials still merge"); + + let settings = &state.editor_ui.agent_settings; + assert_eq!(settings.openverse_client_id, "operator-client"); + assert_eq!(settings.openverse_client_secret, "operator-secret"); + assert_eq!(settings.openverse_credential_owner, None); + assert!(settings + .builtin_agents + .iter() + .any(|agent| agent.api_key == "sk-browser-only")); + assert!(settings + .image_gen_profiles + .iter() + .any(|profile| profile.api_key == "image-browser-only")); +} + +#[test] +fn newer_browser_snapshot_replaces_the_previous_browser_snapshot() { + let mut state = state_with_operator_agent(); + apply_json(&mut state, VALID_BODY).expect("first browser snapshot"); + let second = VALID_BODY + .replace("builtin-web-1", "builtin-web-2") + .replace("igp-web-1", "igp-web-2") + .replace("sk-browser-only", "sk-second-browser") + .replace("image-browser-only", "image-second-browser") + .replace("openverse-browser-only", "openverse-second-browser"); + + apply_json(&mut state, &second).expect("replacement browser snapshot"); + + let settings = &state.editor_ui.agent_settings; + assert_eq!(settings.builtin_agents.len(), 2); + assert!(settings + .builtin_agents + .iter() + .any(|agent| agent.api_key == "operator-secret")); + assert!(settings + .builtin_agents + .iter() + .any(|agent| agent.api_key == "sk-second-browser")); + assert!(!settings + .builtin_agents + .iter() + .any(|agent| agent.api_key == "sk-browser-only")); + assert_eq!(settings.image_gen_profiles.len(), 1); + assert_eq!( + settings.image_gen_profiles[0].api_key, + "image-second-browser" + ); + assert_eq!(settings.openverse_client_secret, "openverse-second-browser"); +} + +#[test] +fn aggregate_entry_limit_is_atomic() { + let mut state = EditorState::new(); + for index in 0..super::MAX_TOTAL_ENTRIES { + state + .editor_ui + .agent_settings + .builtin_agents + .push(BuiltinAgentConfig { + id: format!("operator-{index}"), + preset: BuiltinAgentPresetKey::Custom, + display_name: format!("Operator {index}"), + kind: BuiltinAgentKind::OpenAiCompat, + api_key: format!("operator-secret-{index}"), + model: "operator-model".into(), + base_url: "https://operator.example/v1".into(), + enabled: true, + }); + } + let before = crate::settings_io::fingerprint(&state); + + assert!(apply_json(&mut state, VALID_BODY).is_err()); + assert_eq!(before, crate::settings_io::fingerprint(&state)); +} + +#[test] +fn invalid_late_entry_does_not_partially_mutate_state() { + let mut state = state_with_operator_agent(); + let before = crate::settings_io::fingerprint(&state); + let body = VALID_BODY.replace(r#""provider":"openai""#, r#""provider":"unknown""#); + + assert!(apply_json(&mut state, &body).is_err()); + assert_eq!(before, crate::settings_io::fingerprint(&state)); +} + +#[test] +fn oversized_payload_is_rejected_without_mutation() { + let mut state = state_with_operator_agent(); + let before = crate::settings_io::fingerprint(&state); + let body = "x".repeat(MAX_CREDENTIAL_BODY_BYTES + 1); + + assert!(apply_json(&mut state, &body).is_err()); + assert_eq!(before, crate::settings_io::fingerprint(&state)); +} + +#[test] +fn credential_snapshot_rejects_reserved_provider_endpoints_atomically() { + let endpoints = [ + "https://user:password@api.openai.com/v1", + "http://127.0.0.1:8080/v1", + "http://10.0.0.7/v1", + "http://169.254.169.254/latest/meta-data", + "http://168.63.129.16/metadata/instance", + "http://[::1]:8080/v1", + "http://[fd00:ec2::254]/latest/meta-data", + "http://metadata.google.internal/computeMetadata/v1", + "https://provider.example.test/v1", + "https://attacker.example/v1", + ]; + + for endpoint in endpoints { + let mut state = state_with_operator_agent(); + let before = crate::settings_io::fingerprint(&state); + let body = VALID_BODY.replace("https://api.openai.com/v1", endpoint); + + assert!( + apply_json(&mut state, &body).is_err(), + "reserved endpoint was accepted: {endpoint}" + ); + assert_eq!(before, crate::settings_io::fingerprint(&state)); + } +} + +#[test] +fn credential_snapshot_accepts_a_custom_origin_only_when_explicitly_allowlisted() { + let _guard = EnvVarGuard::unset(super::WEB_AI_ENDPOINT_ALLOWLIST_ENV); + let body = VALID_BODY.replace( + "https://api.openai.com/v1", + "https://allowlisted-credential.example/v1", + ); + let mut state = state_with_operator_agent(); + + assert!( + apply_json(&mut state, &body).is_err(), + "unapproved public origin must be rejected" + ); + + std::env::set_var( + super::WEB_AI_ENDPOINT_ALLOWLIST_ENV, + "https://allowlisted-credential.example", + ); + apply_json(&mut state, &body).expect("explicitly allowlisted origin is accepted"); +} + +#[test] +fn explicit_endpoint_allowlist_can_opt_into_an_internal_origin() { + assert!(validate_web_provider_base_url_with_allowlist( + "http://127.0.0.1:11434/v1", + Some("https://inference.example.com,http://127.0.0.1:11434"), + ) + .is_ok()); + assert!(validate_web_provider_base_url_with_allowlist( + "http://127.0.0.1:11435/v1", + Some("http://127.0.0.1:11434"), + ) + .is_err()); +} + +#[test] +fn browser_credential_endpoint_rejects_every_acp_field_without_mutation() { + for acp_agents in [ + serde_json::json!([]), + serde_json::json!([{"id":"acp-web-7"}]), + ] { + let mut state = state_with_operator_agent(); + state.editor_ui.agent_settings.add_acp_agent_config( + "Operator ACP", + op_editor_core::AcpConnectionType::Local, + "/usr/bin/operator-agent", + vec!["--stdio".into()], + std::collections::BTreeMap::new(), + None, + true, + ); + let before = crate::settings_io::fingerprint(&state); + let mut payload: serde_json::Value = + serde_json::from_str(VALID_BODY).expect("valid fixture"); + payload["acp_agents"] = acp_agents; + + apply_json(&mut state, &payload.to_string()) + .expect_err("ACP is not part of the browser credential schema"); + + assert_eq!(before, crate::settings_io::fingerprint(&state)); + } +} diff --git a/crates/op-host-web/src/canvaskit.rs b/crates/op-host-web/src/canvaskit.rs index b219f2d6f..ae32e3606 100644 --- a/crates/op-host-web/src/canvaskit.rs +++ b/crates/op-host-web/src/canvaskit.rs @@ -2,6 +2,7 @@ //! //! Replaces the from-scratch `wasm32-unknown-unknown` skia build (skia-safe-op //! + hand-rolled libc++/GL shim) with the official CanvasKit skia WASM artifact. +//! //! The Rust side owns all widget/draw logic and drives CanvasKit through the //! thin `op_ck_bridge.js` FFI. `CanvasKitBackend` implements the same //! `RenderBackend` (`jian_widgets::painter::Painter`) the native desktop @@ -707,6 +708,8 @@ pub async fn init_backend( struct CkInner { backend: CanvasKitBackend, host: crate::widget_host::WidgetHost, + settings_fingerprint: Option, + credential_fingerprint: crate::web_settings::CredentialFingerprint, canvas: web_sys::HtmlCanvasElement, /// Hidden ARIA DOM mirror (#57) — kept in sync after every paint so a /// screen reader can read the opaque CanvasKit surface. `None` only if @@ -720,6 +723,7 @@ struct CkInner { impl CkInner { fn repaint(&mut self) { + crate::web_chat::reconcile_models(self.host.editor_state_mut()); let (w, h) = self.backend.logical_size(); self.backend.begin_frame(); self.host.paint_dyn(&mut self.backend, w, h); @@ -731,6 +735,26 @@ impl CkInner { if let Some(ime) = self.ime.as_mut() { ime.sync_focus(self.host.input_active()); } + if crate::web_settings::save_credentials_if_changed( + self.host.editor_state(), + &mut self.credential_fingerprint, + ) + .is_some() + { + if let Some(json) = + crate::web_settings::server_credentials_json(self.host.editor_state()) + { + crate::web_credential_sync::credential_changed(json); + } + } + if !crate::web_settings::credential_migration_pending(&self.credential_fingerprint) { + if let Some(settings_fingerprint) = self.settings_fingerprint.as_mut() { + let _ = crate::web_settings::save_if_changed( + self.host.editor_state(), + settings_fingerprint, + ); + } + } if self.host.layout_transition_active() { crate::repaint_coalescer::request(); } @@ -895,7 +919,15 @@ pub async fn mount_ck(canvas_id: String) -> Result<(), JsValue> { let logical_h = (dev_h / dpr).round().max(1.0) as u32; let backend = init_backend(&canvas_id, dpr, logical_w, logical_h).await?; - let host = crate::widget_host::WidgetHost::new(); + let mut host = crate::widget_host::WidgetHost::new(); + let credential_load = crate::web_settings::load_into(host.editor_state_mut()); + host.mark_editor_state_dirty(); + let settings_fingerprint = credential_load.initial_settings_fingerprint(host.editor_state()); + let credential_fingerprint = credential_load.initial_fingerprint(host.editor_state()); + let initial_credential_json = credential_load + .loaded + .then(|| crate::web_settings::server_credentials_json(host.editor_state())) + .flatten(); // Hidden ARIA DOM mirror (#57) — created next to the canvas, refreshed // after every paint so screen readers can read the opaque GPU surface. let a11y = crate::a11y_dom::A11yDomMirror::create(&canvas); @@ -905,10 +937,16 @@ pub async fn mount_ck(canvas_id: String) -> Result<(), JsValue> { let inner = Rc::new(RefCell::new(CkInner { backend, host, + settings_fingerprint, + credential_fingerprint, canvas: canvas.clone(), a11y, ime, })); + crate::web_credential_sync::start(); + if let Some(json) = initial_credential_json { + crate::web_credential_sync::credential_changed(json); + } { let mut b = inner.borrow_mut(); let _ = b.resize_to_window(&window)?; @@ -1046,8 +1084,6 @@ pub async fn mount_ck(canvas_id: String) -> Result<(), JsValue> { crate::dom_io::drain_pending_attachment_pick(&inner); crate::dom_io::drain_pending_kit_io(&inner); crate::theme_preset_io::drain_pending_theme_preset_io(&inner); - crate::web_agent_connect::drain_pending_provider_connect(&inner); - crate::web_acp_connect::drain_pending_acp_agent_connect(&inner); crate::web_fonts::drain_font_requests(&inner); }, )?; diff --git a/crates/op-host-web/src/codegen_bundle.rs b/crates/op-host-web/src/codegen_bundle.rs index 8813a8108..879a5a300 100644 --- a/crates/op-host-web/src/codegen_bundle.rs +++ b/crates/op-host-web/src/codegen_bundle.rs @@ -6,6 +6,7 @@ //! required. The bundle layout comes from the shared //! [`op_codegen::ai::bundle`] builder (manifest + raw/sanitized views //! + assets), zipped by the dependency-free STORED encoder +//! //! ([`op_codegen::ai::stored_zip`]) so the wasm build stays under the //! bundle-size ceiling. //! diff --git a/crates/op-host-web/src/codegen_web.rs b/crates/op-host-web/src/codegen_web.rs index 20786a51c..5952e4fb0 100644 --- a/crates/op-host-web/src/codegen_web.rs +++ b/crates/op-host-web/src/codegen_web.rs @@ -90,6 +90,9 @@ struct CodegenRun { /// match what was GENERATED even if the user switches tabs afterwards /// (desktop `CodegenSession.framework` parity). framework: op_editor_core::codegen::Framework, + /// Browser-local credential for the selected built-in provider. Captured + /// once at launch and sent only with this run's model requests. + credential: Option, } /// Shared state threaded through the async driver: the run + the delta queue. @@ -135,6 +138,7 @@ pub(crate) fn cancel_active_run() { let mut s = shared.borrow_mut(); s.0.cancelled = true; s.0.pipe.cancel(); + s.0.credential = None; if let Some(handle) = s.0.handle.take() { // Aborting fires the XHR's onloadend, which synthesizes an Error // event — the event callback drops it via the `cancelled` flag. @@ -186,7 +190,7 @@ pub fn start_codegen(inner: Rc>, base: S // 1. Build input from the live editor state. Nothing to generate from // (empty page + no selection) surfaces an inline error (desktop // `launch_codegen_if_pending` parity). - let (input, model, framework) = { + let (input, model, credential, framework) = { let b = inner.borrow(); let state = b.host().editor_state(); let Some(input) = build_codegen_input(state) else { @@ -203,12 +207,8 @@ pub fn start_codegen(inner: Rc>, base: S }; // Model id: the selected chat model's wire value, else "default" (the // proxy then picks the configured provider). - let model = state - .chat - .selected_model_entry() - .map(|e| e.value.clone()) - .unwrap_or_else(|| "default".to_string()); - (input, model, state.codegen.framework) + let (model, credential) = crate::web_ai_credentials::selected_target(state); + (input, model, credential, state.codegen.framework) }; // Reset the panel into the Generating state before the first turn, and @@ -245,6 +245,7 @@ pub fn start_codegen(inner: Rc>, base: S cancelled: false, model, framework, + credential, }, VecDeque::new(), ))); @@ -302,6 +303,7 @@ fn drive(inner: Rc>, base: String, share }); }); s.0.terminal = true; + s.0.credential = None; s.1.push_back(WebCodegenDelta::Done { code, degraded, @@ -311,6 +313,7 @@ fn drive(inner: Rc>, base: String, share } PipelineStep::Failed { message } => { s.0.terminal = true; + s.0.credential = None; s.1.push_back(WebCodegenDelta::Failed(message)); return; } @@ -347,7 +350,10 @@ fn fire_request( s.0.buf.clear(); } - let body_json = build_body_json(&req, &shared.borrow().0.model); + let body_json = { + let run = shared.borrow(); + build_body_json(&req, &run.0.model, run.0.credential.as_ref()) + }; // Cloned handles moved into the streaming callback. let inner_cb = inner.clone(); @@ -419,7 +425,11 @@ fn fire_request( /// prompts) are forwarded; the daemon proxy composes the final system prompt /// (the same `op_ai_skills::compose_system_prompt` the desktop host runs /// in-process). Hand-rolled to avoid a serde derive for this tiny payload. -fn build_body_json(req: &PendingRequest, model: &str) -> String { +fn build_body_json( + req: &PendingRequest, + model: &str, + credential: Option<&serde_json::Value>, +) -> String { let skills_json = req .skills .iter() @@ -432,6 +442,7 @@ fn build_body_json(req: &PendingRequest, model: &str) -> String { "max_output_tokens": req.max_output_tokens, "thinking": req.thinking.as_str(), "effort": req.effort.as_str(), + "credential": credential, }); serde_json::to_string(&body).unwrap_or_else(|_| "{}".to_string()) } @@ -577,6 +588,19 @@ mod tests { use super::*; use op_editor_core::{EditorState, NodeId}; + #[test] + fn codegen_lifecycle_explicitly_clears_request_credentials() { + let source = include_str!("codegen_web.rs"); + let implementation = source + .split("#[cfg(test)]") + .next() + .expect("codegen implementation"); + assert!( + implementation.matches("s.0.credential = None;").count() >= 2, + "cancel and terminal completion must both clear the captured credential" + ); + } + fn two_rect_state() -> EditorState { let doc = jian_ops_schema::load_str( r#"{"version":"0.8.0","children":[ @@ -632,6 +656,27 @@ mod tests { assert_eq!(framework_ext(Framework::Compose), "kt"); } + #[test] + fn codegen_proxy_body_carries_the_selected_request_scoped_credential() { + let req = PendingRequest { + id: RequestId(1), + kind: op_codegen::ai::types::RequestKind::Planning, + skills: vec!["codegen-plan"], + user_message: "plan".into(), + max_output_tokens: 1024, + thinking: op_ai::chat_provider::ThinkingMode::Disabled, + effort: op_ai::chat_provider::EffortLevel::Low, + }; + let credential = serde_json::json!({"api_key":"sk-codegen"}); + + let body: serde_json::Value = + serde_json::from_str(&build_body_json(&req, "private-model", Some(&credential))) + .unwrap(); + + assert_eq!(body["model"], "private-model"); + assert_eq!(body["credential"]["api_key"], "sk-codegen"); + } + #[test] fn code_zip_carries_component_and_each_asset() { let assets = vec![ diff --git a/crates/op-host-web/src/font_store_idb.rs b/crates/op-host-web/src/font_store_idb.rs index d7cf7f1a8..276ce5515 100644 --- a/crates/op-host-web/src/font_store_idb.rs +++ b/crates/op-host-web/src/font_store_idb.rs @@ -20,6 +20,7 @@ use web_sys::{IdbDatabase, IdbObjectStore, IdbOpenDbRequest, IdbTransactionMode} const DB_NAME: &str = "openpencil"; const STORE: &str = "imported_fonts"; const DB_VERSION: u32 = 1; +type FontLoadCallback = Box)>)>; /// CSS font stack → IndexedDB record key. Mirrors the JS `primaryFamilyKey`: /// first family before a comma, quotes stripped, trimmed, lowercased; generic @@ -192,7 +193,7 @@ pub(crate) fn delete_font(family_key: &str) { /// `on_done` is only called on a successful read; any error path logs and drops /// it (mount-time load simply skips — the editor still runs, imports just don't /// survive the reload). -pub(crate) fn load_all(on_done: Box)>)>) { +pub(crate) fn load_all(on_done: FontLoadCallback) { open_db(Box::new(move |db| { let store = match db.transaction_with_str(STORE) { Ok(tx) => match tx.object_store(STORE) { diff --git a/crates/op-host-web/src/lib.rs b/crates/op-host-web/src/lib.rs index 708d39e9c..1890f5247 100644 --- a/crates/op-host-web/src/lib.rs +++ b/crates/op-host-web/src/lib.rs @@ -59,9 +59,7 @@ mod repaint_coalescer; #[cfg(feature = "canvaskit")] mod theme_preset_io; #[cfg(feature = "canvaskit")] -mod web_acp_connect; -#[cfg(feature = "canvaskit")] -mod web_agent_connect; +mod web_ai_credentials; #[cfg(feature = "canvaskit")] mod web_ai_transport; // Web Iconify bridge — drains the icon picker's remote-search request directly @@ -73,9 +71,13 @@ mod iconify_web; #[cfg(feature = "canvaskit")] mod web_chat; #[cfg(feature = "canvaskit")] +mod web_credential_sync; +#[cfg(feature = "canvaskit")] mod web_design_md; #[cfg(feature = "canvaskit")] mod web_settings; +#[cfg(feature = "canvaskit")] +mod web_storage; // Pure web_sys clipboard/download — Ctrl+C/X in inputs + Figma/file paste. #[cfg(feature = "canvaskit")] mod web_clipboard; diff --git a/crates/op-host-web/src/live_sync.rs b/crates/op-host-web/src/live_sync.rs index 1203bb30e..856c159d2 100644 --- a/crates/op-host-web/src/live_sync.rs +++ b/crates/op-host-web/src/live_sync.rs @@ -17,6 +17,8 @@ use std::rc::Rc; use wasm_bindgen::prelude::*; use wasm_bindgen::JsCast; +const STATUS_REQUEST_TIMEOUT_MS: u32 = 15_000; + /// Run `tick` every `interval_ms` for the page lifetime (the interval owns /// the closure — same `forget()` idiom the previous document poll used). pub fn start_interval(interval_ms: i32, tick: Rc) -> Result<(), JsValue> { @@ -58,6 +60,31 @@ pub fn get(url: &str, on_response: Rc) -> bool { xhr.send().is_ok() } +/// Issue one async `GET` and report both HTTP status and response body. +/// Callers that use a response as an authorization decision must use this +/// variant so an error body cannot be mistaken for a successful response. +pub fn get_with_status(url: &str, on_response: Rc) -> bool { + let Ok(xhr) = web_sys::XmlHttpRequest::new() else { + return false; + }; + if xhr.open_with_async("GET", url, true).is_err() { + return false; + } + xhr.set_timeout(STATUS_REQUEST_TIMEOUT_MS); + let xhr_for_load = xhr.clone(); + let onloadend = Closure::::once_into_js(move || { + let status = xhr_for_load.status().unwrap_or(0); + let text = xhr_for_load + .response_text() + .ok() + .flatten() + .unwrap_or_default(); + on_response(status, text); + }); + xhr.set_onloadend(Some(onloadend.unchecked_ref())); + xhr.send().is_ok() +} + /// Issue one async JSON `POST`. `on_response` (when given) receives the /// response body on completion — including error/empty bodies, so an /// in-flight latch held by the caller is always released. Returns `false` @@ -84,3 +111,29 @@ pub fn post_json(url: &str, body: &str, on_response: Option>) } xhr.send_with_opt_str(Some(body)).is_ok() } + +/// Issue one async JSON `POST` and report both HTTP status and body. This is +/// used when a caller must distinguish an acknowledged write from a completed +/// but rejected request. +pub fn post_json_with_status(url: &str, body: &str, on_response: Rc) -> bool { + let Ok(xhr) = web_sys::XmlHttpRequest::new() else { + return false; + }; + if xhr.open_with_async("POST", url, true).is_err() { + return false; + } + xhr.set_timeout(STATUS_REQUEST_TIMEOUT_MS); + let _ = xhr.set_request_header("Content-Type", "application/json"); + let xhr_for_load = xhr.clone(); + let onloadend = Closure::::once_into_js(move || { + let status = xhr_for_load.status().unwrap_or(0); + let text = xhr_for_load + .response_text() + .ok() + .flatten() + .unwrap_or_default(); + on_response(status, text); + }); + xhr.set_onloadend(Some(onloadend.unchecked_ref())); + xhr.send_with_opt_str(Some(body)).is_ok() +} diff --git a/crates/op-host-web/src/web_ai_credentials.rs b/crates/op-host-web/src/web_ai_credentials.rs new file mode 100644 index 000000000..7ec2f50fe --- /dev/null +++ b/crates/op-host-web/src/web_ai_credentials.rs @@ -0,0 +1,77 @@ +use op_editor_core::EditorState; + +/// Resolve the selected model to the daemon wire id and, for a browser-local +/// built-in provider, attach exactly that provider's request-scoped +/// credential. Non-built-in/daemon models carry no credential. +pub(crate) fn selected_target(state: &EditorState) -> (String, Option) { + let selected = state.chat.selected_model_entry(); + let selected_builtin = selected + .and_then(|entry| entry.builtin_provider_id.as_deref()) + .and_then(|id| { + state + .editor_ui + .agent_settings + .builtin_agents + .iter() + .find(|agent| agent.id == id) + }); + let model = selected_builtin + .map(|agent| agent.model.clone()) + .or_else(|| selected.map(|entry| entry.value.clone())) + .unwrap_or_else(|| "default".to_string()); + let credential = selected_builtin.map(|agent| { + serde_json::json!({ + "id": agent.id, + "preset": agent.preset.as_str(), + "display_name": agent.display_name, + "kind": match agent.kind { + op_editor_core::BuiltinAgentKind::Anthropic => "anthropic", + op_editor_core::BuiltinAgentKind::OpenAiCompat => "openai-compat", + }, + "api_key": agent.api_key, + "model": agent.model, + "base_url": agent.base_url, + "enabled": agent.enabled, + }) + }); + (model, credential) +} + +#[cfg(test)] +mod tests { + use super::selected_target; + use op_editor_core::{BuiltinAgentKind, EditorState}; + + #[test] + fn selected_target_attaches_only_the_selected_browser_builtin() { + let mut state = EditorState::new(); + let selected_id = state.editor_ui.agent_settings.add_builtin_agent_config( + "Private", + "sk-selected", + "private-model", + BuiltinAgentKind::OpenAiCompat, + "https://api.openai.com/v1", + ); + state.editor_ui.agent_settings.add_builtin_agent_config( + "Other", + "sk-other", + "other-model", + BuiltinAgentKind::OpenAiCompat, + "https://api.openai.com/v1", + ); + state.rebuild_chat_models(); + state.chat.selected_model = state + .chat + .available_models + .iter() + .position(|entry| entry.builtin_provider_id.as_deref() == Some(selected_id.as_str())) + .unwrap(); + + let (model, credential) = selected_target(&state); + let credential = credential.expect("selected credential"); + + assert_eq!(model, "private-model"); + assert_eq!(credential["api_key"], "sk-selected"); + assert!(!credential.to_string().contains("sk-other")); + } +} diff --git a/crates/op-host-web/src/web_ai_transport.rs b/crates/op-host-web/src/web_ai_transport.rs index 68e1dd25d..1ee94ef31 100644 --- a/crates/op-host-web/src/web_ai_transport.rs +++ b/crates/op-host-web/src/web_ai_transport.rs @@ -14,7 +14,7 @@ //! round-trip itself still needs a running daemon + browser to verify. #![allow(dead_code)] -use std::cell::Cell; +use std::cell::{Cell, RefCell}; use std::rc::Rc; use wasm_bindgen::closure::Closure; @@ -65,9 +65,8 @@ impl AiStreamHandle { /// The `onprogress` closure tracks a consumed-length cursor: each tick parses /// only fully-terminated `data:` blocks (those before the last blank-line /// separator) and advances the cursor past that separator, so a partial tail is -/// re-parsed on the next tick and no block is delivered twice. The closure is -/// `forget()`-leaked (page-scoped like `live_sync::start_interval`); the XHR owns it for -/// the duration of the request. A one-shot `onloadend` runs a final drain and — +/// re-parsed on the next tick and no block is delivered twice. A one-shot +/// `onloadend` detaches and drops the progress closure, then runs a final drain and — /// when the stream ended without a terminal `done` / `error` event (daemon /// down, non-200, connection dropped mid-stream) — synthesizes an /// [`AiEvent::Error`] so the caller's turn never hangs in the streaming state. @@ -97,6 +96,7 @@ pub fn post_ai_stream_to( // event was delivered, so onloadend can detect an abnormal end. let cursor = Rc::new(Cell::new(0usize)); let saw_terminal = Rc::new(Cell::new(false)); + let terminal_for_send_error = saw_terminal.clone(); let xhr_for_cb = xhr.clone(); let on_event_cb = on_event.clone(); @@ -115,15 +115,23 @@ pub fn post_ai_stream_to( on_event_cb(evt); } }); - xhr.set_onprogress(Some(onprogress.as_ref().unchecked_ref())); - onprogress.forget(); // lives until the XHR completes (page-scoped like live_sync) + let progress_holder = Rc::new(RefCell::new(Some(onprogress))); + xhr.set_onprogress( + progress_holder + .borrow() + .as_ref() + .map(|callback| callback.as_ref().unchecked_ref()), + ); // One-shot end-of-request hook (fires for load, error, AND abort): drain // any tail the last onprogress missed, then synthesize an Error if the // stream ended without a terminal event. After an abort the caller has // already dropped its turn, so the synthesized event is simply ignored. let xhr_for_end = xhr.clone(); + let progress_holder_for_end = progress_holder.clone(); let onloadend = Closure::::once_into_js(move || { + xhr_for_end.set_onprogress(None); + progress_holder_for_end.borrow_mut().take(); if let Ok(Some(text)) = xhr_for_end.response_text() { let (events, next) = drain_sse_buffer(&text, cursor.get()); cursor.set(next); @@ -143,7 +151,19 @@ pub fn post_ai_stream_to( }); xhr.set_onloadend(Some(onloadend.unchecked_ref())); - xhr.send_with_opt_str(Some(&body_json))?; + if let Err(error) = xhr.send_with_opt_str(Some(&body_json)) { + xhr.set_onprogress(None); + progress_holder.borrow_mut().take(); + xhr.set_onloadend(None); + // `once_into_js` releases its Rust capture when invoked. Mark this as + // terminal and invoke the now-detached callback so a synchronous send + // error cannot leave its XHR/on_event capture waiting for JS GC. + terminal_for_send_error.set(true); + let _ = onloadend + .unchecked_ref::() + .call0(&wasm_bindgen::JsValue::NULL); + return Err(error); + } Ok(AiStreamHandle { xhr }) } @@ -240,6 +260,19 @@ fn nonempty_str(obj: &serde_json::Map, key: &str) -> mod tests { use super::*; + #[test] + fn xhr_progress_callback_has_terminal_cleanup_instead_of_a_page_lifetime_leak() { + let source = include_str!("web_ai_transport.rs"); + let implementation = source + .split("#[cfg(test)]") + .next() + .expect("transport implementation"); + assert!(!implementation.contains(&["onprogress", ".forget()"].concat())); + assert!(implementation.contains("xhr_for_end.set_onprogress(None)")); + assert!(implementation.contains("progress_holder_for_end.borrow_mut().take()")); + assert!(implementation.contains("xhr.set_onloadend(None)")); + } + #[test] fn parse_event_maps_each_wire_shape() { assert_eq!( diff --git a/crates/op-host-web/src/web_chat.rs b/crates/op-host-web/src/web_chat.rs index f802ea0ad..632b932f1 100644 --- a/crates/op-host-web/src/web_chat.rs +++ b/crates/op-host-web/src/web_chat.rs @@ -40,6 +40,7 @@ use crate::repaint_ctx::RepaintContext; use crate::web_ai_transport::{post_ai_stream_to, AiEvent, AiStreamHandle}; type EventQueue = Rc>>; +const DAEMON_BUILTIN_PREFIX: &str = "daemon-builtin:"; /// The in-flight chat turn: the XHR abort handle plus its generation. wasm is /// single-threaded, so a thread_local slot is the natural owner — every drain @@ -281,11 +282,7 @@ pub(crate) struct PreparedTurn { /// transcript bubble. pub(crate) fn prepare_turn(state: &mut EditorState) -> Option { let user_text = state.chat.pending_send.take()?; - let model = state - .chat - .selected_model_entry() - .map(|e| e.value.clone()) - .unwrap_or_else(|| "default".to_string()); + let (model, credential) = crate::web_ai_credentials::selected_target(state); let thinking = state.chat.thinking_mode.as_str(); let effort = state.chat.effort_level.as_str(); let agent_team_size = state.chat.agent_team_size; @@ -325,6 +322,7 @@ pub(crate) fn prepare_turn(state: &mut EditorState) -> Option { .map(|page| page.id.as_str()); let body = serde_json::json!({ "model": model, + "credential": credential, // Standard turns route through the daemon classifier + design // orchestrator; skills are resolved daemon-side per route. "skills": [], @@ -424,9 +422,9 @@ pub(crate) fn parse_models_json(body: &str) -> Vec { .collect() } -/// Heuristic provider tag for a daemon model id — drives only the picker's -/// branding/grouping on web (the daemon resolves the real provider by id, so -/// a mismatch here cannot misroute a request). +/// Heuristic provider tag for a daemon built-in model id. This drives only the +/// API-key branding in the picker; the daemon resolves the real built-in by +/// model id. pub(crate) fn provider_for_model_id(id: &str) -> AgentProvider { let lower = id.to_ascii_lowercase(); if lower.contains("gemini") { @@ -441,31 +439,84 @@ pub(crate) fn provider_for_model_id(id: &str) -> AgentProvider { } } -/// Install the daemon catalog into the chat model picker. Writes both -/// `discovered_models` and `available_models` directly — the web shell has no -/// connected-CLI mask (the daemon owns provider configuration), so the -/// desktop's connected-providers filter does not apply. The previously -/// selected model is preserved by identity when it survives, else selection -/// falls back to the first entry. +/// Merge the daemon's built-in-only catalog into the chat model picker. A +/// synthetic built-in id keeps these entries out of every CLI path; because it +/// does not match a browser-local profile, requests carry no transient key and +/// the daemon resolves its operator-owned built-in by model id. pub(crate) fn apply_models(state: &mut EditorState, ids: &[String]) { - let entries: Vec = ids + state.chat.discovered_models = ids .iter() - .map(|id| ModelEntry::new(provider_for_model_id(id), id.clone(), id.clone())) + .map(|id| { + ModelEntry::builtin_with_display_name( + provider_for_model_id(id), + format!("{DAEMON_BUILTIN_PREFIX}{id}"), + "Server API Key", + id.clone(), + id.clone(), + ) + }) .collect(); - let prev = state + + reconcile_models(state); +} + +/// Restore the web model picker from its two authoritative built-in-only +/// sources: the daemon catalog and ready browser-local providers. +/// +/// Core settings mutations call `EditorState::rebuild_chat_models`, whose +/// native connected-provider filter can temporarily remove daemon models. +/// Re-running this web reconciliation makes those models visible again while +/// keeping the selected entry by identity whenever it still exists. +pub(crate) fn reconcile_models(state: &mut EditorState) { + let previous = state .chat .available_models .get(state.chat.selected_model) .cloned(); - state.chat.discovered_models = entries.clone(); - state.chat.available_models = entries; - state.chat.selected_model = prev + let local_agents = state + .editor_ui + .agent_settings + .builtin_agents + .iter() + .filter(|agent| agent.ready()) + .collect::>(); + let local_model_ids = local_agents + .iter() + .map(|agent| agent.model.trim()) + .collect::>(); + let mut available_models = state + .chat + .discovered_models + .iter() + .filter(|entry| { + entry + .builtin_provider_id + .as_deref() + .is_some_and(|id| id.starts_with(DAEMON_BUILTIN_PREFIX)) + && !local_model_ids.contains(entry.value.trim()) + }) + .cloned() + .collect::>(); + available_models.extend(local_agents.into_iter().map(|agent| { + ModelEntry::builtin_with_display_name( + agent.kind.model_provider(), + agent.id.clone(), + agent.display_name.clone(), + format!("builtin:{}:{}", agent.id, agent.model), + agent.model.clone(), + ) + })); + if state.chat.available_models == available_models { + return; + } + state.chat.available_models = available_models; + state.chat.selected_model = previous .and_then(|p| { - state - .chat - .available_models - .iter() - .position(|m| m.provider == p.provider && m.value == p.value) + state.chat.available_models.iter().position(|m| { + m.provider == p.provider + && m.value == p.value + && m.builtin_provider_id == p.builtin_provider_id + }) }) .unwrap_or(0); } @@ -674,6 +725,11 @@ mod tests { assert_eq!(state.chat.available_models.len(), 2); assert_eq!(state.chat.discovered_models.len(), 2); assert_eq!(state.chat.selected_model, 0); + assert!(state + .chat + .available_models + .iter() + .all(|entry| entry.builtin_provider_id.is_some())); assert_eq!( state.chat.available_models[1].provider, AgentProvider::GeminiCli @@ -714,3 +770,7 @@ mod tests { ); } } + +#[cfg(test)] +#[path = "web_chat_credential_tests.rs"] +mod credential_tests; diff --git a/crates/op-host-web/src/web_chat_credential_tests.rs b/crates/op-host-web/src/web_chat_credential_tests.rs new file mode 100644 index 000000000..169f9c9d1 --- /dev/null +++ b/crates/op-host-web/src/web_chat_credential_tests.rs @@ -0,0 +1,213 @@ +use super::*; + +fn state_with_queued_send(text: &str) -> EditorState { + let mut state = EditorState::new(); + state.chat.set_input_text(text); + assert!(state.chat.begin_send()); + state +} + +#[test] +fn prepare_turn_attaches_only_the_selected_builtin_credential() { + let mut state = state_with_queued_send("hello"); + let selected_id = state.editor_ui.agent_settings.add_builtin_agent_config( + "Private", + "sk-selected", + "private-model", + op_editor_core::BuiltinAgentKind::OpenAiCompat, + "https://example.test/v1", + ); + state.editor_ui.agent_settings.add_builtin_agent_config( + "Other", + "sk-must-not-leak", + "other-model", + op_editor_core::BuiltinAgentKind::OpenAiCompat, + "https://other.test/v1", + ); + state.rebuild_chat_models(); + state.chat.selected_model = state + .chat + .available_models + .iter() + .position(|entry| entry.builtin_provider_id.as_deref() == Some(selected_id.as_str())) + .expect("selected built-in model"); + + let body: serde_json::Value = serde_json::from_str( + &prepare_turn(&mut state) + .expect("send was pending") + .body_json, + ) + .expect("body is JSON"); + + assert_eq!(body["model"], "private-model"); + assert_eq!(body["credential"]["api_key"], "sk-selected"); + assert_eq!(body["credential"]["id"], selected_id); + assert!(!body.to_string().contains("sk-must-not-leak")); +} + +#[test] +fn prepare_turn_uses_null_credential_for_a_daemon_builtin_model() { + let mut state = state_with_queued_send("hello"); + state.chat.available_models = vec![ModelEntry::builtin_with_display_name( + AgentProvider::ClaudeCode, + "daemon-builtin:claude-sonnet-4-5", + "Server API Key", + "claude-sonnet-4-5", + "Claude Sonnet 4.5", + )]; + + let body: serde_json::Value = serde_json::from_str( + &prepare_turn(&mut state) + .expect("send was pending") + .body_json, + ) + .expect("body is JSON"); + + assert!(body + .get("credential") + .is_some_and(serde_json::Value::is_null)); +} + +#[test] +fn daemon_model_refresh_preserves_browser_local_builtin_models() { + let mut state = EditorState::new(); + let local_id = state.editor_ui.agent_settings.add_builtin_agent_config( + "Private", + "sk-local", + "private-model", + op_editor_core::BuiltinAgentKind::OpenAiCompat, + "https://example.test/v1", + ); + state.rebuild_chat_models(); + + apply_models(&mut state, &["server-model".into()]); + + assert!(state + .chat + .available_models + .iter() + .any(|entry| entry.value == "server-model")); + assert!(state + .chat + .available_models + .iter() + .any(|entry| { entry.builtin_provider_id.as_deref() == Some(local_id.as_str()) })); +} + +#[test] +fn reconcile_models_restores_daemon_catalog_after_core_rebuild() { + let mut state = EditorState::new(); + let local_id = state.editor_ui.agent_settings.add_builtin_agent_config( + "Private", + "sk-local", + "private-model", + op_editor_core::BuiltinAgentKind::OpenAiCompat, + "https://example.test/v1", + ); + apply_models(&mut state, &["server-model".into()]); + state.chat.selected_model = state + .chat + .available_models + .iter() + .position(|entry| entry.builtin_provider_id.as_deref() == Some(local_id.as_str())) + .expect("browser-local model is selectable"); + + state.rebuild_chat_models(); + assert!( + state + .chat + .available_models + .iter() + .all(|entry| entry.value != "server-model"), + "the regression precondition reproduces the core filter" + ); + + reconcile_models(&mut state); + + assert!(state + .chat + .available_models + .iter() + .any(|entry| entry.value == "server-model")); + assert_eq!( + state + .chat + .selected_model_entry() + .and_then(|entry| entry.builtin_provider_id.as_deref()), + Some(local_id.as_str()) + ); +} + +#[test] +fn reconcile_models_hides_acp_until_the_web_proxy_supports_acp_chat() { + let mut state = EditorState::new(); + let id = state.editor_ui.agent_settings.add_acp_agent_config( + "Local ACP", + op_editor_core::AcpConnectionType::Local, + "op-agent", + Vec::new(), + std::collections::BTreeMap::new(), + None, + true, + ); + state + .editor_ui + .agent_settings + .apply_acp_agent_connect_outcome( + &id, + op_editor_core::AcpAgentConnectOutcome { + connected: true, + info: Some("Local ACP".into()), + error: None, + }, + ); + state.rebuild_chat_models(); + assert!(state + .chat + .available_models + .iter() + .any(|entry| entry.value == format!("acp:{id}"))); + + reconcile_models(&mut state); + + assert!(state + .chat + .available_models + .iter() + .all(|entry| entry.value != format!("acp:{id}"))); +} + +#[test] +fn reconcile_models_hides_cli_models_from_the_builtin_only_web_surface() { + let mut state = EditorState::new(); + state.chat.discovered_models = vec![ModelEntry::new( + AgentProvider::CodexCli, + "gpt-cli", + "CLI Model", + )]; + + reconcile_models(&mut state); + + assert!(state.chat.available_models.is_empty()); +} + +#[test] +fn canvaskit_reconciles_web_models_before_painting() { + let source = include_str!("canvaskit.rs"); + let repaint = source + .split("impl CkInner {") + .nth(1) + .and_then(|body| body.split("fn sync_a11y").next()) + .expect("CkInner repaint source"); + let reconcile = repaint + .find("crate::web_chat::reconcile_models(self.host.editor_state_mut())") + .expect("repaint reconciles the daemon and browser model catalogs"); + let paint = repaint + .find("self.host.paint_dyn") + .expect("repaint paints the host"); + + assert!( + reconcile < paint, + "the picker must be reconciled before paint" + ); +} diff --git a/crates/op-host-web/src/web_credential_sync.rs b/crates/op-host-web/src/web_credential_sync.rs new file mode 100644 index 000000000..c42cc3601 --- /dev/null +++ b/crates/op-host-web/src/web_credential_sync.rs @@ -0,0 +1,415 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use serde::Deserialize; + +const MAX_RETRY_ATTEMPTS: u8 = 5; +const INITIAL_RETRY_DELAY_MS: i32 = 1_000; + +#[derive(Debug, Default)] +struct CredentialSyncState { + server_persistence: Option, + pending: Option, + in_flight: Option, + policy_in_flight: bool, + retry_attempts: u8, + retry_generation: u64, +} + +#[derive(Debug, PartialEq, Eq)] +enum SyncAction { + None, + FetchPolicy, + Post(String), + ScheduleRetry { delay_ms: i32, generation: u64 }, +} + +impl CredentialSyncState { + fn start(&mut self) -> SyncAction { + *self = Self::default(); + self.begin_policy_check() + } + + fn changed(&mut self, json: String) -> SyncAction { + self.pending = Some(json); + self.retry_attempts = 0; + self.invalidate_retry(); + if self.policy_in_flight || self.in_flight.is_some() { + return SyncAction::None; + } + self.begin_policy_check() + } + + fn resolve_policy(&mut self, server_persistence: Option) -> SyncAction { + self.policy_in_flight = false; + self.server_persistence = server_persistence; + + match server_persistence { + Some(false) => { + self.pending = None; + self.retry_attempts = 0; + self.invalidate_retry(); + SyncAction::None + } + None => self.schedule_retry(), + Some(true) if self.in_flight.is_some() => SyncAction::None, + Some(true) => { + self.invalidate_retry(); + let Some(json) = self.pending.take() else { + return SyncAction::None; + }; + self.in_flight = Some(json.clone()); + SyncAction::Post(json) + } + } + } + + fn complete(&mut self, status: Option) -> SyncAction { + let Some(completed) = self.in_flight.take() else { + return SyncAction::None; + }; + + if status == Some(403) { + self.server_persistence = Some(false); + self.pending = None; + self.retry_attempts = 0; + self.invalidate_retry(); + return SyncAction::None; + } + + if status.is_some_and(|status| (200..300).contains(&status)) { + self.retry_attempts = 0; + self.invalidate_retry(); + if self.pending.is_some() { + return self.begin_policy_check(); + } + return SyncAction::None; + } + + if self.pending.is_none() { + self.pending = Some(completed); + } + self.schedule_retry() + } + + #[cfg_attr(not(any(test, target_arch = "wasm32")), allow(dead_code))] + fn retry_due(&mut self, generation: u64) -> SyncAction { + if generation != self.retry_generation + || self.pending.is_none() + || self.policy_in_flight + || self.in_flight.is_some() + { + return SyncAction::None; + } + self.begin_policy_check() + } + + fn begin_policy_check(&mut self) -> SyncAction { + self.policy_in_flight = true; + SyncAction::FetchPolicy + } + + fn schedule_retry(&mut self) -> SyncAction { + if self.pending.is_none() || self.retry_attempts >= MAX_RETRY_ATTEMPTS { + return SyncAction::None; + } + self.retry_attempts += 1; + self.retry_generation = self.retry_generation.wrapping_add(1); + let shift = u32::from(self.retry_attempts.saturating_sub(1)); + let delay_ms = INITIAL_RETRY_DELAY_MS.saturating_mul(1_i32 << shift); + SyncAction::ScheduleRetry { + delay_ms, + generation: self.retry_generation, + } + } + + fn invalidate_retry(&mut self) { + self.retry_generation = self.retry_generation.wrapping_add(1); + } +} + +#[derive(Deserialize)] +struct CredentialPolicyResponse { + #[serde(rename = "serverPersistence")] + server_persistence: bool, +} + +thread_local! { + static SYNC_STATE: RefCell = RefCell::new(CredentialSyncState::default()); +} + +pub(crate) fn start() { + let action = SYNC_STATE.with(|state| state.borrow_mut().start()); + dispatch(action); +} + +pub(crate) fn credential_changed(json: String) { + let action = SYNC_STATE.with(|state| state.borrow_mut().changed(json)); + dispatch(action); +} + +fn parse_policy_response(status: u16, body: &str) -> Option { + if !(200..300).contains(&status) { + return None; + } + serde_json::from_str::(body) + .ok() + .map(|response| response.server_persistence) +} + +fn dispatch(action: SyncAction) { + match action { + SyncAction::None => {} + SyncAction::FetchPolicy => fetch_policy(), + SyncAction::Post(body) => post_credentials(&body), + SyncAction::ScheduleRetry { + delay_ms, + generation, + } => schedule_retry(delay_ms, generation), + } +} + +fn fetch_policy() { + let base = crate::daemon_base::daemon_base(); + let on_response: Rc = Rc::new(|status, body| { + let policy = parse_policy_response(status, &body); + if policy.is_none() { + report_sync_failure(Some(status)); + } + let action = SYNC_STATE.with(|state| state.borrow_mut().resolve_policy(policy)); + dispatch(action); + }); + if !crate::live_sync::get_with_status( + &format!("{base}/api/settings/credential-policy"), + on_response, + ) { + report_sync_failure(None); + let action = SYNC_STATE.with(|state| state.borrow_mut().resolve_policy(None)); + dispatch(action); + } +} + +fn post_credentials(body: &str) { + let base = crate::daemon_base::daemon_base(); + let on_response: Rc = Rc::new(|status, _| { + if !(200..300).contains(&status) { + report_sync_failure(Some(status)); + } + complete_in_flight(Some(status)); + }); + if !crate::live_sync::post_json_with_status( + &format!("{base}/api/settings/credentials"), + body, + on_response, + ) { + report_sync_failure(None); + complete_in_flight(None); + } +} + +fn complete_in_flight(status: Option) { + let action = SYNC_STATE.with(|state| state.borrow_mut().complete(status)); + dispatch(action); +} + +fn schedule_retry(delay_ms: i32, generation: u64) { + #[cfg(target_arch = "wasm32")] + { + use wasm_bindgen::JsCast; + + let callback = wasm_bindgen::closure::Closure::::once_into_js(move || { + let action = SYNC_STATE.with(|state| state.borrow_mut().retry_due(generation)); + dispatch(action); + }); + let scheduled = web_sys::window().is_some_and(|window| { + window + .set_timeout_with_callback_and_timeout_and_arguments_0( + callback.unchecked_ref(), + delay_ms, + ) + .is_ok() + }); + if !scheduled { + report_sync_failure(None); + } + } + #[cfg(not(target_arch = "wasm32"))] + let _ = (delay_ms, generation); +} + +fn report_sync_failure(status: Option) { + #[cfg(target_arch = "wasm32")] + { + let message = status.map_or_else( + || "OpenPencil could not synchronize server credentials".to_string(), + |status| format!("OpenPencil server credential synchronization failed ({status})"), + ); + web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(&message)); + } + #[cfg(not(target_arch = "wasm32"))] + let _ = status; +} + +#[cfg(test)] +mod tests { + use super::{parse_policy_response, CredentialSyncState, SyncAction, MAX_RETRY_ATTEMPTS}; + + #[test] + fn pending_change_waits_for_policy_and_is_dropped_when_disabled() { + let mut sync = CredentialSyncState::default(); + assert_eq!(sync.start(), SyncAction::FetchPolicy); + assert_eq!(sync.changed("secret-json".into()), SyncAction::None); + assert_eq!(sync.resolve_policy(Some(false)), SyncAction::None); + assert!(sync.pending.is_none()); + } + + #[test] + fn pending_change_posts_after_fresh_server_policy_is_enabled() { + let mut sync = CredentialSyncState::default(); + assert_eq!(sync.start(), SyncAction::FetchPolicy); + assert_eq!(sync.changed("secret-json".into()), SyncAction::None); + assert_eq!( + sync.resolve_policy(Some(true)), + SyncAction::Post("secret-json".into()) + ); + } + + #[test] + fn credential_change_rechecks_cached_policy_before_posting() { + let mut sync = CredentialSyncState::default(); + assert_eq!(sync.start(), SyncAction::FetchPolicy); + assert_eq!(sync.resolve_policy(Some(true)), SyncAction::None); + + assert_eq!(sync.changed("secret-json".into()), SyncAction::FetchPolicy); + assert_eq!( + sync.resolve_policy(Some(true)), + SyncAction::Post("secret-json".into()) + ); + } + + #[test] + fn later_change_rechecks_a_cached_disabled_policy() { + let mut sync = CredentialSyncState::default(); + assert_eq!(sync.start(), SyncAction::FetchPolicy); + assert_eq!(sync.resolve_policy(Some(false)), SyncAction::None); + + assert_eq!(sync.changed("new-secret".into()), SyncAction::FetchPolicy); + assert_eq!(sync.pending.as_deref(), Some("new-secret")); + } + + #[test] + fn enabled_policy_posts_one_at_a_time_and_rechecks_for_latest_pending_change() { + let mut sync = CredentialSyncState::default(); + assert_eq!(sync.changed("credential-a".into()), SyncAction::FetchPolicy); + assert_eq!( + sync.resolve_policy(Some(true)), + SyncAction::Post("credential-a".into()) + ); + assert_eq!(sync.changed("credential-b".into()), SyncAction::None); + assert_eq!(sync.changed("credential-c".into()), SyncAction::None); + assert_eq!(sync.pending.as_deref(), Some("credential-c")); + + assert_eq!(sync.complete(Some(204)), SyncAction::FetchPolicy); + assert_eq!( + sync.resolve_policy(Some(true)), + SyncAction::Post("credential-c".into()) + ); + assert_eq!(sync.complete(Some(204)), SyncAction::None); + } + + #[test] + fn failed_final_post_retries_after_a_bounded_delay() { + let mut sync = CredentialSyncState::default(); + assert_eq!(sync.changed("credential-a".into()), SyncAction::FetchPolicy); + assert_eq!( + sync.resolve_policy(Some(true)), + SyncAction::Post("credential-a".into()) + ); + + let action = sync.complete(Some(500)); + let SyncAction::ScheduleRetry { generation, .. } = action else { + panic!("a failed post should schedule a retry"); + }; + assert_eq!(sync.retry_due(generation), SyncAction::FetchPolicy); + assert_eq!( + sync.resolve_policy(Some(true)), + SyncAction::Post("credential-a".into()) + ); + } + + #[test] + fn repeated_post_failures_stop_after_the_retry_limit() { + let mut sync = CredentialSyncState::default(); + assert_eq!(sync.changed("credential-a".into()), SyncAction::FetchPolicy); + assert_eq!( + sync.resolve_policy(Some(true)), + SyncAction::Post("credential-a".into()) + ); + + for _ in 0..MAX_RETRY_ATTEMPTS { + let SyncAction::ScheduleRetry { generation, .. } = sync.complete(Some(500)) else { + panic!("retry budget should not be exhausted yet"); + }; + assert_eq!(sync.retry_due(generation), SyncAction::FetchPolicy); + assert_eq!( + sync.resolve_policy(Some(true)), + SyncAction::Post("credential-a".into()) + ); + } + + assert_eq!(sync.complete(Some(500)), SyncAction::None); + assert_eq!(sync.retry_attempts, MAX_RETRY_ATTEMPTS); + } + + #[test] + fn forbidden_post_demotes_policy_and_drops_pending_secrets() { + let mut sync = CredentialSyncState::default(); + assert_eq!(sync.changed("credential-a".into()), SyncAction::FetchPolicy); + assert_eq!( + sync.resolve_policy(Some(true)), + SyncAction::Post("credential-a".into()) + ); + assert_eq!(sync.changed("credential-b".into()), SyncAction::None); + + assert_eq!(sync.complete(Some(403)), SyncAction::None); + assert_eq!(sync.server_persistence, Some(false)); + assert!(sync.pending.is_none()); + } + + #[test] + fn stale_retry_is_ignored_after_a_new_change() { + let mut sync = CredentialSyncState::default(); + assert_eq!(sync.changed("credential-a".into()), SyncAction::FetchPolicy); + assert_eq!( + sync.resolve_policy(Some(true)), + SyncAction::Post("credential-a".into()) + ); + let SyncAction::ScheduleRetry { generation, .. } = sync.complete(None) else { + panic!("transport failure should schedule a retry"); + }; + + assert_eq!(sync.changed("credential-b".into()), SyncAction::FetchPolicy); + assert_eq!(sync.retry_due(generation), SyncAction::None); + } + + #[test] + fn policy_response_requires_success_status_and_valid_boolean_json() { + assert_eq!( + parse_policy_response(500, r#"{"serverPersistence":true}"#), + None + ); + assert_eq!(parse_policy_response(200, "{}"), None); + assert_eq!( + parse_policy_response(200, r#"{"serverPersistence":"true"}"#), + None + ); + assert_eq!( + parse_policy_response(200, r#"{"serverPersistence":true}"#), + Some(true) + ); + assert_eq!( + parse_policy_response(200, r#"{"serverPersistence":false}"#), + Some(false) + ); + } +} diff --git a/crates/op-host-web/src/web_design_md.rs b/crates/op-host-web/src/web_design_md.rs index d3e28ace2..76ce894f6 100644 --- a/crates/op-host-web/src/web_design_md.rs +++ b/crates/op-host-web/src/web_design_md.rs @@ -293,10 +293,8 @@ fn build_auto_generate_body(state: &EditorState) -> Option { if state.active_children().is_empty() { return None; } - let model = state - .chat - .selected_model_entry() - .map(|entry| entry.value.clone())?; + state.chat.selected_model_entry()?; + let (model, credential) = crate::web_ai_credentials::selected_target(state); let user = build_design_md_user_message(state); let body = serde_json::json!({ "model": model, @@ -305,6 +303,7 @@ fn build_auto_generate_body(state: &EditorState) -> Option { "max_output_tokens": 8192u32, "thinking": "disabled", "effort": "high", + "credential": credential, }); serde_json::to_string(&body).ok() } @@ -397,3 +396,34 @@ fn strip_tool_call_blocks(text: &str) -> String { fn console_error(msg: &str) { web_sys::console::error_1(&JsValue::from_str(msg)); } + +#[cfg(test)] +mod tests { + use super::build_auto_generate_body; + use op_editor_core::{BuiltinAgentKind, EditorState}; + + #[test] + fn design_md_proxy_body_carries_the_selected_request_scoped_credential() { + let mut state = EditorState::starter(); + let selected_id = state.editor_ui.agent_settings.add_builtin_agent_config( + "Private", + "sk-design-md", + "private-model", + BuiltinAgentKind::OpenAiCompat, + "https://api.openai.com/v1", + ); + state.rebuild_chat_models(); + state.chat.selected_model = state + .chat + .available_models + .iter() + .position(|entry| entry.builtin_provider_id.as_deref() == Some(selected_id.as_str())) + .unwrap(); + + let body: serde_json::Value = + serde_json::from_str(&build_auto_generate_body(&state).expect("request body")).unwrap(); + + assert_eq!(body["model"], "private-model"); + assert_eq!(body["credential"]["api_key"], "sk-design-md"); + } +} diff --git a/crates/op-host-web/src/web_settings.rs b/crates/op-host-web/src/web_settings.rs index 75e29b0af..644fe1668 100644 --- a/crates/op-host-web/src/web_settings.rs +++ b/crates/op-host-web/src/web_settings.rs @@ -7,13 +7,31 @@ use op_editor_core::editor_ui_state::{RecentFile, RECENT_FILE_CAP}; use op_editor_core::{ - AcpAgentConfig, AcpConnectionType, BuiltinAgentConfig, BuiltinAgentKind, BuiltinAgentPresetKey, - EditorState, ImageGenProfile, ImageGenProvider, Locale, ThemeMode, + BuiltinAgentConfig, BuiltinAgentKind, BuiltinAgentPresetKey, EditorState, ImageGenProfile, + ImageGenProvider, Locale, ThemeMode, }; use serde::{Deserialize, Serialize}; const SETTINGS_VERSION: u32 = 1; +const CREDENTIAL_PAYLOAD_VERSION: u32 = 2; const STORAGE_KEY: &str = "openpencil-rust-web-settings"; +const CREDENTIAL_STORAGE_KEY: &str = "openpencil-rust-web-credentials"; + +#[path = "web_settings_legacy.rs"] +mod legacy; +#[path = "web_settings_storage.rs"] +mod storage; +#[path = "web_settings_validation.rs"] +mod validation; + +#[cfg(test)] +use storage::{ + apply_stored_snapshots, load_into_with, save_credentials_if_changed_with, save_if_changed_with, + StoredCredentialSource, +}; +pub(crate) use storage::{ + credential_migration_pending, load_into, save_credentials_if_changed, save_if_changed, +}; #[derive(Debug, Serialize, Deserialize, Clone)] struct RecentFilePayload { @@ -35,22 +53,6 @@ struct BuiltinAgentPayload { enabled: bool, } -#[derive(Debug, Serialize, Deserialize, Clone)] -struct AcpAgentPayload { - id: String, - display_name: String, - connection_type: String, - #[serde(default)] - command: String, - #[serde(default)] - args: Vec, - #[serde(default)] - env: std::collections::BTreeMap, - #[serde(default)] - url: Option, - enabled: bool, -} - #[derive(Debug, Serialize, Deserialize, Clone)] struct ImageGenProfilePayload { id: String, @@ -75,16 +77,22 @@ pub(crate) struct Fingerprint { port: u16, cli: [bool; 6], images_adv: bool, - openverse_client_id: String, - openverse_client_secret: String, auto_update_enabled: bool, experimental_features_enabled: bool, - connected: [bool; 5], + recent_files: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CredentialFingerprint { builtin_agents: Vec, - acp_agents: Vec, image_gen_profiles: Vec, active_image_gen_profile_id: Option, - recent_files: Vec, + openverse_client_id: String, + openverse_client_secret: String, + write_pending: bool, + write_disabled: bool, + pending_credential_json: Option, + pending_settings_json: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -100,26 +108,31 @@ struct SettingsPayload { mcp_cli_enabled: Option<[bool; 6]>, #[serde(default)] images_advanced_open: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] openverse_oauth: Option, #[serde(default)] auto_update_enabled: Option, #[serde(default)] experimental_features_enabled: Option, - #[serde(default)] - connected: Option<[bool; 5]>, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] builtin_agents: Option>, - #[serde(default)] - acp_agents: Option>, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] image_gen_profiles: Option>, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] active_image_gen_profile_id: Option, #[serde(default)] recent_files: Option>, } +#[derive(Debug, Serialize, Deserialize)] +struct CredentialPayload { + version: u32, + builtin_agents: Vec, + image_gen_profiles: Vec, + active_image_gen_profile_id: Option, + openverse_oauth: Option, +} + pub(crate) fn fingerprint(state: &EditorState) -> Fingerprint { let eui = &state.editor_ui; Fingerprint { @@ -128,45 +141,71 @@ pub(crate) fn fingerprint(state: &EditorState) -> Fingerprint { port: eui.agent_settings.mcp_server.port, cli: eui.agent_settings.mcp_cli_enabled, images_adv: eui.agent_settings.images_advanced_open, - openverse_client_id: eui.agent_settings.openverse_client_id.clone(), - openverse_client_secret: eui.agent_settings.openverse_client_secret.clone(), auto_update_enabled: eui.agent_settings.auto_update_enabled, experimental_features_enabled: eui.agent_settings.experimental_features_enabled, - connected: eui.agent_settings.connected, - builtin_agents: eui.agent_settings.builtin_agents.clone(), - acp_agents: eui.agent_settings.acp_agents.clone(), - image_gen_profiles: eui.agent_settings.image_gen_profiles.clone(), - active_image_gen_profile_id: eui.agent_settings.active_image_gen_profile_id.clone(), recent_files: eui.recent_files.clone(), } } -pub(crate) fn load_into(state: &mut EditorState) { - let Some(raw) = storage_get(STORAGE_KEY) else { - return; - }; - let _ = apply_json(state, &raw); -} - -pub(crate) fn save_if_changed(state: &EditorState, before: &mut Fingerprint) { - let next = fingerprint(state); - if next != *before { - save(state); - *before = next; +pub(crate) fn credential_fingerprint(state: &EditorState) -> CredentialFingerprint { + let settings = &state.editor_ui.agent_settings; + CredentialFingerprint { + builtin_agents: settings.builtin_agents.clone(), + image_gen_profiles: settings.image_gen_profiles.clone(), + active_image_gen_profile_id: settings.active_image_gen_profile_id.clone(), + openverse_client_id: settings.openverse_client_id.clone(), + openverse_client_secret: settings.openverse_client_secret.clone(), + write_pending: false, + write_disabled: false, + pending_credential_json: None, + pending_settings_json: None, } } +pub(crate) fn credentials_json(state: &EditorState) -> Option { + serde_json::to_string(&credential_payload(state)).ok() +} + +/// Snapshot sent to the daemon when server persistence is explicitly enabled. +/// The web credential schema contains built-in providers and image services, +/// never CLI or ACP agent configuration. +pub(crate) fn server_credentials_json(state: &EditorState) -> Option { + credentials_json(state) +} + +fn credential_payload(state: &EditorState) -> CredentialPayload { + let settings = &state.editor_ui.agent_settings; + CredentialPayload { + version: CREDENTIAL_PAYLOAD_VERSION, + builtin_agents: settings + .builtin_agents + .iter() + .map(builtin_agent_to_payload) + .collect(), + image_gen_profiles: settings + .image_gen_profiles + .iter() + .map(image_gen_profile_to_payload) + .collect(), + active_image_gen_profile_id: settings.active_image_gen_profile_id.clone(), + openverse_oauth: openverse_oauth_to_payload(settings), + } +} + +#[cfg(test)] fn apply_json(state: &mut EditorState, raw: &str) -> Result<(), String> { - let payload: SettingsPayload = serde_json::from_str(raw).map_err(|e| e.to_string())?; + let value: serde_json::Value = serde_json::from_str(raw).map_err(|e| e.to_string())?; + let payload = validation::settings_payload(&value)?; apply_payload(state, payload); Ok(()) } -fn save(state: &EditorState) { - let Ok(json) = serde_json::to_string(&to_payload(state)) else { - return; - }; - storage_set(STORAGE_KEY, &json); +#[cfg(test)] +fn apply_credential_json(state: &mut EditorState, raw: &str) -> Result<(), String> { + let value: serde_json::Value = serde_json::from_str(raw).map_err(|error| error.to_string())?; + let payload = validation::credential_payload(&value)?; + apply_credential_payload(state, payload); + Ok(()) } fn to_payload(state: &EditorState) -> SettingsPayload { @@ -178,32 +217,12 @@ fn to_payload(state: &EditorState) -> SettingsPayload { mcp_port: Some(eui.agent_settings.mcp_server.port), mcp_cli_enabled: Some(eui.agent_settings.mcp_cli_enabled), images_advanced_open: Some(eui.agent_settings.images_advanced_open), - openverse_oauth: openverse_oauth_to_payload(&eui.agent_settings), + openverse_oauth: None, auto_update_enabled: Some(eui.agent_settings.auto_update_enabled), experimental_features_enabled: Some(eui.agent_settings.experimental_features_enabled), - connected: Some(eui.agent_settings.connected), - builtin_agents: Some( - eui.agent_settings - .builtin_agents - .iter() - .map(builtin_agent_to_payload) - .collect(), - ), - acp_agents: Some( - eui.agent_settings - .acp_agents - .iter() - .map(acp_agent_to_payload) - .collect(), - ), - image_gen_profiles: Some( - eui.agent_settings - .image_gen_profiles - .iter() - .map(image_gen_profile_to_payload) - .collect(), - ), - active_image_gen_profile_id: eui.agent_settings.active_image_gen_profile_id.clone(), + builtin_agents: None, + image_gen_profiles: None, + active_image_gen_profile_id: None, recent_files: Some( eui.recent_files .iter() @@ -246,9 +265,6 @@ fn apply_payload(state: &mut EditorState, payload: SettingsPayload) { if let Some(enabled) = payload.experimental_features_enabled { eui.agent_settings.experimental_features_enabled = enabled; } - if let Some(connected) = payload.connected { - eui.agent_settings.connected = connected; - } if let Some(agents) = payload.builtin_agents { let agents = agents .into_iter() @@ -258,13 +274,6 @@ fn apply_payload(state: &mut EditorState, payload: SettingsPayload) { eui.agent_settings.next_builtin_agent_id = next_builtin_agent_id(&eui.agent_settings.builtin_agents); } - if let Some(agents) = payload.acp_agents { - eui.agent_settings.acp_agents = agents - .into_iter() - .filter_map(acp_agent_from_payload) - .collect(); - eui.agent_settings.next_acp_agent_id = next_acp_agent_id(&eui.agent_settings.acp_agents); - } if let Some(profiles) = payload.image_gen_profiles { eui.agent_settings.image_gen_profiles = profiles .into_iter() @@ -309,6 +318,46 @@ fn apply_payload(state: &mut EditorState, payload: SettingsPayload) { state.rebuild_chat_models(); } +fn apply_credential_payload(state: &mut EditorState, payload: CredentialPayload) { + let settings = &mut state.editor_ui.agent_settings; + settings.builtin_agents = dedupe_builtin_agents( + payload + .builtin_agents + .into_iter() + .filter_map(builtin_agent_from_payload) + .collect(), + ); + settings.next_builtin_agent_id = next_builtin_agent_id(&settings.builtin_agents); + settings.image_gen_profiles = payload + .image_gen_profiles + .into_iter() + .filter_map(image_gen_profile_from_payload) + .collect(); + settings.next_image_gen_profile_id = next_image_gen_profile_id(&settings.image_gen_profiles); + settings.active_image_gen_profile_id = payload + .active_image_gen_profile_id + .filter(|active| { + settings + .image_gen_profiles + .iter() + .any(|profile| profile.id == *active) + }) + .or_else(|| { + settings + .image_gen_profiles + .first() + .map(|profile| profile.id.clone()) + }); + if let Some(oauth) = payload.openverse_oauth { + settings.openverse_client_id = oauth.client_id; + settings.openverse_client_secret = oauth.client_secret; + } else { + settings.openverse_client_id.clear(); + settings.openverse_client_secret.clear(); + } + state.rebuild_chat_models(); +} + fn builtin_agent_to_payload(agent: &BuiltinAgentConfig) -> BuiltinAgentPayload { BuiltinAgentPayload { id: agent.id.clone(), @@ -358,42 +407,6 @@ fn builtin_agent_from_payload(payload: BuiltinAgentPayload) -> Option AcpAgentPayload { - AcpAgentPayload { - id: agent.id.clone(), - display_name: agent.display_name.clone(), - connection_type: match agent.connection_type { - AcpConnectionType::Local => "local", - AcpConnectionType::Remote => "remote", - } - .into(), - command: agent.command.clone(), - args: agent.args.clone(), - env: agent.env.clone(), - url: agent.url.clone(), - enabled: agent.enabled, - } -} - -fn acp_agent_from_payload(payload: AcpAgentPayload) -> Option { - let connection_type = match payload.connection_type.as_str() { - "local" => AcpConnectionType::Local, - "remote" => AcpConnectionType::Remote, - _ => return None, - }; - Some(AcpAgentConfig { - id: payload.id, - display_name: payload.display_name, - connection_type, - command: payload.command, - args: payload.args, - env: payload.env, - url: payload.url, - enabled: payload.enabled, - connected: false, - }) -} - fn image_gen_profile_to_payload(profile: &ImageGenProfile) -> ImageGenProfilePayload { ImageGenProfilePayload { id: profile.id.clone(), @@ -473,15 +486,6 @@ fn next_builtin_agent_id(agents: &[BuiltinAgentConfig]) -> u64 { .saturating_add(1) } -fn next_acp_agent_id(agents: &[AcpAgentConfig]) -> u64 { - agents - .iter() - .filter_map(|agent| agent.id.strip_prefix("acp-")?.parse::().ok()) - .max() - .unwrap_or(0) - .saturating_add(1) -} - fn next_image_gen_profile_id(profiles: &[ImageGenProfile]) -> u64 { profiles .iter() @@ -546,163 +550,14 @@ fn str_to_locale(s: &str) -> Option { }) } -#[cfg(target_arch = "wasm32")] -fn storage_get(key: &str) -> Option { - web_sys::window() - .and_then(|window| window.local_storage().ok().flatten()) - .and_then(|storage| storage.get_item(key).ok().flatten()) -} - -#[cfg(not(target_arch = "wasm32"))] -fn storage_get(_key: &str) -> Option { - None -} - -#[cfg(target_arch = "wasm32")] -fn storage_set(key: &str, value: &str) { - if let Some(storage) = - web_sys::window().and_then(|window| window.local_storage().ok().flatten()) - { - let _ = storage.set_item(key, value); - } -} - -#[cfg(not(target_arch = "wasm32"))] -fn storage_set(_key: &str, _value: &str) {} +#[cfg(test)] +#[path = "web_settings_tests.rs"] +mod tests; #[cfg(test)] -mod tests { - use super::*; - use op_editor_core::{EditorState, Locale, ThemeMode}; +#[path = "web_settings_acp_scrub_tests.rs"] +mod acp_scrub_tests; - #[test] - fn settings_payload_restores_theme_and_locale() { - let payload = r#"{"version":1,"theme":"light","locale":"en-US"}"#; - let mut state = EditorState::new(); - - apply_json(&mut state, payload).expect("settings payload should parse"); - - assert_eq!(state.editor_ui.theme_mode, ThemeMode::Light); - assert_eq!(state.editor_ui.locale, Locale::EnUs); - } - - #[test] - fn fingerprint_changes_when_theme_changes() { - let mut state = EditorState::new(); - let before = fingerprint(&state); - - state.editor_ui.theme_mode = ThemeMode::Light; - - assert_ne!(before, fingerprint(&state)); - } - - #[test] - fn settings_payload_round_trips_recent_files_and_mcp_preferences() { - let mut src = EditorState::new(); - src.editor_ui.theme_mode = ThemeMode::Light; - src.editor_ui.locale = Locale::Ja; - src.editor_ui.agent_settings.mcp_server.port = 4321; - src.editor_ui.agent_settings.mcp_cli_enabled[1] = true; - src.editor_ui.agent_settings.images_advanced_open = true; - src.editor_ui.agent_settings.openverse_client_id = "client".into(); - src.editor_ui.agent_settings.openverse_client_secret = "secret".into(); - src.editor_ui.agent_settings.auto_update_enabled = false; - src.editor_ui.agent_settings.experimental_features_enabled = true; - src.editor_ui.agent_settings.connected = [true, false, false, true, false]; - src.editor_ui.recent_files = vec![ - RecentFile { - path: "/tmp/a.op".into(), - modified_at: 1, - }, - RecentFile { - path: "/tmp/b.op".into(), - modified_at: 2, - }, - ]; - let json = serde_json::to_string(&to_payload(&src)).expect("settings serialize"); - let mut dst = EditorState::new(); - - apply_json(&mut dst, &json).expect("settings payload parses"); - - assert_eq!(dst.editor_ui.theme_mode, ThemeMode::Light); - assert_eq!(dst.editor_ui.locale, Locale::Ja); - assert_eq!(dst.editor_ui.agent_settings.mcp_server.port, 4321); - assert!(dst.editor_ui.agent_settings.mcp_cli_enabled[1]); - assert!(dst.editor_ui.agent_settings.images_advanced_open); - assert_eq!(dst.editor_ui.agent_settings.openverse_client_id, "client"); - assert_eq!( - dst.editor_ui.agent_settings.openverse_client_secret, - "secret" - ); - assert!(!dst.editor_ui.agent_settings.auto_update_enabled); - assert!(dst.editor_ui.agent_settings.experimental_features_enabled); - assert_eq!( - dst.editor_ui.agent_settings.connected, - [true, false, false, true, false] - ); - assert_eq!(dst.editor_ui.recent_files.len(), 2); - assert_eq!(dst.editor_ui.recent_files[0].path, "/tmp/a.op"); - } - - #[test] - fn settings_payload_round_trips_agent_and_image_profiles() { - let mut src = EditorState::new(); - src.editor_ui.agent_settings.add_builtin_agent_config( - "MiniMax", - "sk-test", - "MiniMax-M2.7", - BuiltinAgentKind::Anthropic, - "https://api.minimaxi.com/anthropic", - ); - src.editor_ui.agent_settings.add_acp_agent_config( - "Design Agent", - AcpConnectionType::Local, - "/usr/local/bin/design-agent", - vec!["--stdio".into()], - std::collections::BTreeMap::new(), - None, - true, - ); - let image_profile_id = src.editor_ui.agent_settings.add_image_gen_profile(); - let profile = &mut src.editor_ui.agent_settings.image_gen_profiles[0]; - profile.name = "Gemini Image".into(); - profile.provider = ImageGenProvider::Gemini; - profile.api_key = "image-key".into(); - profile.model = "gemini-image".into(); - assert!(src - .editor_ui - .agent_settings - .set_active_image_gen_profile(&image_profile_id)); - let json = serde_json::to_string(&to_payload(&src)).expect("settings serialize"); - let mut dst = EditorState::new(); - - apply_json(&mut dst, &json).expect("settings payload parses"); - - assert_eq!(dst.editor_ui.agent_settings.builtin_agents.len(), 1); - assert_eq!( - dst.editor_ui.agent_settings.builtin_agents[0].display_name, - "MiniMax" - ); - assert_eq!( - dst.editor_ui.agent_settings.builtin_agents[0].preset, - BuiltinAgentPresetKey::MiniMax - ); - assert_eq!(dst.editor_ui.agent_settings.acp_agents.len(), 1); - assert_eq!( - dst.editor_ui.agent_settings.acp_agents[0].command, - "/usr/local/bin/design-agent" - ); - assert_eq!(dst.editor_ui.agent_settings.image_gen_profiles.len(), 1); - assert_eq!( - dst.editor_ui.agent_settings.image_gen_profiles[0].provider, - ImageGenProvider::Gemini - ); - assert_eq!( - dst.editor_ui - .agent_settings - .active_image_gen_profile_id - .as_deref(), - Some(image_profile_id.as_str()) - ); - } -} +#[cfg(test)] +#[path = "web_settings_lossless_tests.rs"] +mod lossless_tests; diff --git a/crates/op-host-web/src/web_settings_acp_scrub_tests.rs b/crates/op-host-web/src/web_settings_acp_scrub_tests.rs new file mode 100644 index 000000000..a21c47424 --- /dev/null +++ b/crates/op-host-web/src/web_settings_acp_scrub_tests.rs @@ -0,0 +1,411 @@ +use super::*; +use op_editor_core::EditorState; + +#[test] +fn legacy_acp_in_separate_credentials_is_removed_without_losing_builtin_keys() { + let credentials = r#"{ + "version": 2, + "builtin_agents": [{ + "id": "builtin-1", + "preset": "custom", + "display_name": "Current", + "kind": "openai-compat", + "api_key": "sk-must-survive-acp-removal", + "model": "current-model", + "base_url": "https://api.openai.com/v1", + "enabled": true + }], + "acp_agents": [{ + "id": "acp-legacy", + "command": "acp-command-secret", + "env": {"TOKEN": "acp-env-secret"} + }], + "image_gen_profiles": [], + "active_image_gen_profile_id": null, + "openverse_oauth": null + }"#; + let mut state = EditorState::new(); + let mut writes = Vec::new(); + + let load = load_into_with(&mut state, None, Some(credentials), |key, json| { + writes.push((key.to_string(), json.to_string())); + true + }); + + assert!(load.loaded); + assert!(!load.write_pending); + assert_eq!(state.editor_ui.agent_settings.builtin_agents.len(), 1); + assert_eq!( + state.editor_ui.agent_settings.builtin_agents[0].api_key, + "sk-must-survive-acp-removal" + ); + assert!(state.editor_ui.agent_settings.acp_agents.is_empty()); + assert_eq!(writes.len(), 1); + assert_eq!(writes[0].0, CREDENTIAL_STORAGE_KEY); + assert!(writes[0].1.contains("sk-must-survive-acp-removal")); + assert!(!writes[0].1.contains("acp_agents")); + assert!(!writes[0].1.contains("acp-command-secret")); + assert!(!writes[0].1.contains("acp-env-secret")); +} + +#[test] +fn separate_acp_scrub_does_not_touch_future_general_settings() { + let future_general = r#"{ + "version": 2, + "future_field": "must-survive" + }"#; + let credentials = r#"{ + "version": 2, + "builtin_agents": [{ + "id": "builtin-1", + "preset": "custom", + "display_name": "Current", + "kind": "openai-compat", + "api_key": "sk-must-survive-acp-removal", + "model": "current-model", + "base_url": "https://api.openai.com/v1", + "enabled": true + }], + "acp_agents": [{ + "id": "acp-legacy", + "command": "acp-command-secret" + }], + "image_gen_profiles": [], + "active_image_gen_profile_id": null, + "openverse_oauth": null + }"#; + let mut state = EditorState::new(); + let mut writes = Vec::new(); + + let load = load_into_with( + &mut state, + Some(future_general), + Some(credentials), + |key, json| { + writes.push((key.to_string(), json.to_string())); + true + }, + ); + + assert!(load.loaded); + assert!(load.unsupported_version); + assert!(load.initial_settings_fingerprint(&state).is_none()); + assert_eq!(writes.len(), 1); + assert_eq!(writes[0].0, CREDENTIAL_STORAGE_KEY); + assert!(writes[0].1.contains("sk-must-survive-acp-removal")); + assert!(!writes[0].1.contains("acp_agents")); + assert!(!writes[0].1.contains("acp-command-secret")); +} + +#[test] +fn acp_is_scrubbed_from_general_and_separate_snapshots_in_one_ordered_migration() { + let legacy_general = r#"{ + "version": 1, + "connected": [true, true, true, true, true], + "acp_agents": [{ + "id": "acp-general", + "command": "general-acp-secret" + }] + }"#; + let credentials = r#"{ + "version": 2, + "builtin_agents": [{ + "id": "builtin-1", + "preset": "custom", + "display_name": "Current", + "kind": "openai-compat", + "api_key": "sk-current", + "model": "current-model", + "base_url": "https://api.openai.com/v1", + "enabled": true + }], + "acp_agents": [{ + "id": "acp-separate", + "command": "separate-acp-secret" + }], + "image_gen_profiles": [], + "active_image_gen_profile_id": null, + "openverse_oauth": null + }"#; + let mut state = EditorState::new(); + let mut writes = Vec::new(); + + let load = load_into_with( + &mut state, + Some(legacy_general), + Some(credentials), + |key, json| { + writes.push((key.to_string(), json.to_string())); + true + }, + ); + + assert!(load.loaded); + assert!(!load.write_pending); + assert_eq!(writes.len(), 2); + assert_eq!(writes[0].0, CREDENTIAL_STORAGE_KEY); + assert_eq!(writes[1].0, STORAGE_KEY); + for (_, json) in &writes { + assert!(!json.contains("acp_agents")); + assert!(!json.contains("general-acp-secret")); + assert!(!json.contains("separate-acp-secret")); + assert!(!json.contains("connected")); + } + assert!(writes[0].1.contains("sk-current")); +} + +#[test] +fn legacy_acp_configuration_is_removed_without_loading_or_migrating_it() { + let legacy = r#"{ + "version": 1, + "connected": [true, true, true, true, true], + "acp_agents": [{ + "id": "acp-7", + "display_name": "Legacy ACP", + "connection_type": "remote", + "command": "legacy-command-secret", + "args": ["--token=legacy-args-secret"], + "env": {"TOKEN": "legacy-env-secret"}, + "url": "https://user:legacy-url-secret@example.test/acp", + "enabled": true + }] + }"#; + let mut state = EditorState::new(); + let mut writes = Vec::new(); + + let load = load_into_with(&mut state, Some(legacy), None, |key, json| { + writes.push((key.to_string(), json.to_string())); + true + }); + + assert!(!load.loaded); + assert!(!load.write_pending); + assert_eq!(writes.len(), 1); + assert_eq!(writes[0].0, STORAGE_KEY); + assert!(!writes[0].1.contains("acp_agents")); + assert!(!writes[0].1.contains("connected")); + assert!(!writes[0].1.contains("legacy-command-secret")); + assert!(!writes[0].1.contains("legacy-args-secret")); + assert!(!writes[0].1.contains("legacy-env-secret")); + assert!(!writes[0].1.contains("legacy-url-secret")); + assert!(state.editor_ui.agent_settings.acp_agents.is_empty()); + assert_eq!(state.editor_ui.agent_settings.connected, [false; 5]); +} + +#[test] +fn failed_supported_acp_only_scrub_retries_without_authoritative_empty_credentials() { + let settings = r#"{ + "version": 1, + "connected": [true, true, true, true, true], + "acp_agents": [{"command":"supported-acp-secret"}] + }"#; + let mut state = EditorState::new(); + let mut initial_writes = Vec::new(); + + let load = load_into_with(&mut state, Some(settings), None, |key, json| { + initial_writes.push((key.to_string(), json.to_string())); + false + }); + + assert!(!load.loaded); + assert!(!load.unsupported_version); + assert!(load.write_pending); + assert_eq!(initial_writes.len(), 1); + assert_eq!(initial_writes[0].0, STORAGE_KEY); + + let mut baseline = load.initial_fingerprint(&state); + let mut retry_writes = Vec::new(); + let saved = save_credentials_if_changed_with(&state, &mut baseline, |key, json| { + retry_writes.push((key.to_string(), json.to_string())); + true + }); + + assert!(saved.is_none()); + assert!(!credential_migration_pending(&baseline)); + assert_eq!(retry_writes.len(), 1); + assert_eq!(retry_writes[0].0, STORAGE_KEY); + assert!(!retry_writes[0].1.contains("acp_agents")); + assert!(!retry_writes[0].1.contains("connected")); + assert!(!retry_writes[0].1.contains("supported-acp-secret")); + + state.editor_ui.agent_settings.add_builtin_agent_config( + "New local credential", + "sk-user-edit", + "new-model", + BuiltinAgentKind::OpenAiCompat, + "https://api.openai.com/v1", + ); + let mut user_writes = Vec::new(); + assert!( + save_credentials_if_changed_with(&state, &mut baseline, |key, json| { + user_writes.push((key.to_string(), json.to_string())); + true + }) + .is_some() + ); + assert_eq!(user_writes.len(), 1); + assert_eq!(user_writes[0].0, CREDENTIAL_STORAGE_KEY); + assert!(user_writes[0].1.contains("sk-user-edit")); +} + +#[test] +fn failed_read_only_credential_scrub_retries_without_enabling_user_writes() { + let credentials = r#"{ + "version": 2, + "builtin_agents": [], + "acp_agents": [{"command":"acp-secret"}], + "image_gen_profiles": [], + "active_image_gen_profile_id": null, + "openverse_oauth": null, + "future_credentials": {"token":"future-secret-must-survive"} + }"#; + let mut state = EditorState::new(); + let mut initial_writes = Vec::new(); + + let load = load_into_with(&mut state, None, Some(credentials), |key, json| { + initial_writes.push((key.to_string(), json.to_string())); + false + }); + + assert!(!load.loaded); + assert!(load.unsupported_version); + assert!(load.write_pending); + assert_eq!(initial_writes.len(), 1); + assert_eq!(initial_writes[0].0, CREDENTIAL_STORAGE_KEY); + + let mut baseline = load.initial_fingerprint(&state); + let mut retry_writes = Vec::new(); + let saved = save_credentials_if_changed_with(&state, &mut baseline, |key, json| { + retry_writes.push((key.to_string(), json.to_string())); + true + }); + + assert!(saved.is_none()); + assert!(!credential_migration_pending(&baseline)); + assert_eq!(retry_writes.len(), 1); + assert_eq!(retry_writes[0].0, CREDENTIAL_STORAGE_KEY); + assert!(!retry_writes[0].1.contains("acp_agents")); + assert!(!retry_writes[0].1.contains("acp-secret")); + assert!(retry_writes[0].1.contains("future-secret-must-survive")); + + state.editor_ui.agent_settings.add_builtin_agent_config( + "Must stay read-only", + "sk-must-not-write", + "future-model", + BuiltinAgentKind::OpenAiCompat, + "https://api.openai.com/v1", + ); + let mut user_writes = 0; + assert!( + save_credentials_if_changed_with(&state, &mut baseline, |_, _| { + user_writes += 1; + true + }) + .is_none() + ); + assert_eq!(user_writes, 0); +} + +#[test] +fn failed_read_only_general_scrub_retries_without_creating_credential_snapshot() { + let settings = r#"{ + "version": 1, + "acp_agents": [{"command":"general-acp-secret"}], + "connected": [true, true, true, true, true], + "future_settings": {"token":"future-general-secret-must-survive"} + }"#; + let mut state = EditorState::new(); + let mut initial_writes = Vec::new(); + + let load = load_into_with(&mut state, Some(settings), None, |key, json| { + initial_writes.push((key.to_string(), json.to_string())); + false + }); + + assert!(!load.loaded); + assert!(load.unsupported_version); + assert!(load.write_pending); + assert_eq!(initial_writes.len(), 1); + assert_eq!(initial_writes[0].0, STORAGE_KEY); + + let mut baseline = load.initial_fingerprint(&state); + let mut retry_writes = Vec::new(); + let saved = save_credentials_if_changed_with(&state, &mut baseline, |key, json| { + retry_writes.push((key.to_string(), json.to_string())); + true + }); + + assert!(saved.is_none()); + assert!(!credential_migration_pending(&baseline)); + assert_eq!(retry_writes.len(), 1); + assert_eq!(retry_writes[0].0, STORAGE_KEY); + assert!(!retry_writes[0].1.contains("acp_agents")); + assert!(!retry_writes[0].1.contains("connected")); + assert!(!retry_writes[0].1.contains("general-acp-secret")); + assert!(retry_writes[0] + .1 + .contains("future-general-secret-must-survive")); +} + +#[test] +fn partial_read_only_scrub_retry_clears_only_the_successful_write() { + let settings = r#"{ + "version": 1, + "acp_agents": [{"command":"general-acp-secret"}], + "future_settings": {"token":"future-general-secret"} + }"#; + let credentials = r#"{ + "version": 2, + "builtin_agents": [], + "acp_agents": [{"command":"credential-acp-secret"}], + "image_gen_profiles": [], + "active_image_gen_profile_id": null, + "openverse_oauth": null, + "future_credentials": {"token":"future-credential-secret"} + }"#; + let mut state = EditorState::new(); + let mut initial_writes = Vec::new(); + let load = load_into_with( + &mut state, + Some(settings), + Some(credentials), + |key, json| { + initial_writes.push((key.to_string(), json.to_string())); + false + }, + ); + + assert!(load.write_pending); + assert_eq!(initial_writes.len(), 1); + assert_eq!(initial_writes[0].0, CREDENTIAL_STORAGE_KEY); + + let mut baseline = load.initial_fingerprint(&state); + let mut first_retry = Vec::new(); + assert!( + save_credentials_if_changed_with(&state, &mut baseline, |key, json| { + first_retry.push((key.to_string(), json.to_string())); + key == CREDENTIAL_STORAGE_KEY + }) + .is_none() + ); + assert!(credential_migration_pending(&baseline)); + assert_eq!(first_retry.len(), 2); + assert_eq!(first_retry[0].0, CREDENTIAL_STORAGE_KEY); + assert_eq!(first_retry[1].0, STORAGE_KEY); + assert!(first_retry[0].1.contains("future-credential-secret")); + assert!(!first_retry[0].1.contains("credential-acp-secret")); + + let mut second_retry = Vec::new(); + assert!( + save_credentials_if_changed_with(&state, &mut baseline, |key, json| { + second_retry.push((key.to_string(), json.to_string())); + true + }) + .is_none() + ); + assert!(!credential_migration_pending(&baseline)); + assert_eq!(second_retry.len(), 1); + assert_eq!(second_retry[0].0, STORAGE_KEY); + assert!(second_retry[0].1.contains("future-general-secret")); + assert!(!second_retry[0].1.contains("general-acp-secret")); +} diff --git a/crates/op-host-web/src/web_settings_legacy.rs b/crates/op-host-web/src/web_settings_legacy.rs new file mode 100644 index 000000000..49758cda6 --- /dev/null +++ b/crates/op-host-web/src/web_settings_legacy.rs @@ -0,0 +1,102 @@ +//! Legacy browser-settings inspection and credential-field sanitization. + +use super::{SettingsPayload, SETTINGS_VERSION}; + +pub(super) struct PreparedSettings { + pub(super) payload: Option, + pub(super) sanitized_raw: Option, + pub(super) unsupported_version: bool, + pub(super) had_legacy_credentials: bool, +} + +pub(super) fn prepare(raw: &str) -> PreparedSettings { + let Ok(mut value) = serde_json::from_str::(raw) else { + return PreparedSettings { + payload: None, + sanitized_raw: None, + unsupported_version: true, + had_legacy_credentials: false, + }; + }; + if value.get("version").and_then(serde_json::Value::as_u64) != Some(u64::from(SETTINGS_VERSION)) + { + return PreparedSettings { + payload: None, + sanitized_raw: None, + unsupported_version: true, + had_legacy_credentials: false, + }; + } + let Some(object) = value.as_object_mut() else { + return PreparedSettings { + payload: None, + sanitized_raw: None, + unsupported_version: true, + had_legacy_credentials: false, + }; + }; + + let had_legacy_credentials = [ + "openverse_oauth", + "builtin_agents", + "image_gen_profiles", + "active_image_gen_profile_id", + ] + .iter() + .any(|key| object.contains_key(*key)); + + // ACP and the legacy CLI connection cache are forbidden in the web store. + // Remove only those fields before validating the rest, so an incompatible + // same-version snapshot can retain every unrelated future field verbatim. + let mut removed_forbidden = false; + for key in ["acp_agents", "connected"] { + removed_forbidden |= object.remove(key).is_some(); + } + + let payload = match super::validation::settings_payload(&value) { + Ok(payload) => payload, + Err(_) => { + let sanitized_raw = removed_forbidden + .then(|| serde_json::to_string(&value).ok()) + .flatten(); + return PreparedSettings { + payload: None, + sanitized_raw, + unsupported_version: true, + had_legacy_credentials, + }; + } + }; + + // Supported v1 snapshots may still carry the pre-split credential fields. + // They can now be migrated to the dedicated credential key losslessly. + let object = value + .as_object_mut() + .expect("browser settings object was checked above"); + let mut sanitized = removed_forbidden; + for key in [ + "openverse_oauth", + "builtin_agents", + "image_gen_profiles", + "active_image_gen_profile_id", + ] { + sanitized |= object.remove(key).is_some(); + } + + if !sanitized { + return PreparedSettings { + payload: Some(payload), + sanitized_raw: None, + unsupported_version: false, + had_legacy_credentials, + }; + } + + let sanitized_raw = serde_json::to_string(&value).ok(); + PreparedSettings { + payload: Some(payload), + sanitized_raw, + unsupported_version: false, + had_legacy_credentials, + } +} diff --git a/crates/op-host-web/src/web_settings_lossless_tests.rs b/crates/op-host-web/src/web_settings_lossless_tests.rs new file mode 100644 index 000000000..57d9f9e7c --- /dev/null +++ b/crates/op-host-web/src/web_settings_lossless_tests.rs @@ -0,0 +1,291 @@ +use super::*; +use op_editor_core::{BuiltinAgentKind, EditorState, ThemeMode}; + +fn valid_credentials() -> serde_json::Value { + serde_json::json!({ + "version": 2, + "builtin_agents": [{ + "id": "builtin-1", + "preset": "custom", + "display_name": "Current", + "kind": "openai-compat", + "api_key": "sk-current-secret", + "model": "current-model", + "base_url": "https://api.openai.com/v1", + "enabled": true + }], + "image_gen_profiles": [], + "active_image_gen_profile_id": null, + "openverse_oauth": null + }) +} + +fn assert_credential_snapshot_is_read_only(value: serde_json::Value) { + let raw = serde_json::to_string(&value).expect("encode credential fixture"); + let mut state = EditorState::new(); + let mut writes = Vec::new(); + let load = load_into_with(&mut state, None, Some(&raw), |key, json| { + writes.push((key.to_string(), json.to_string())); + true + }); + + assert!(!load.loaded); + assert!(load.unsupported_version); + assert!( + writes.is_empty(), + "incompatible raw credentials must survive" + ); + assert!(state.editor_ui.agent_settings.builtin_agents.is_empty()); + + let mut baseline = load.initial_fingerprint(&state); + state.editor_ui.agent_settings.add_builtin_agent_config( + "Edited", + "sk-edited", + "edited-model", + BuiltinAgentKind::OpenAiCompat, + "https://api.openai.com/v1", + ); + assert!( + save_credentials_if_changed_with(&state, &mut baseline, |key, json| { + writes.push((key.to_string(), json.to_string())); + true + }) + .is_none() + ); + assert!(writes.is_empty()); +} + +#[test] +fn same_version_unknown_credential_fields_remain_read_only() { + let mut top_level = valid_credentials(); + top_level["future_credentials"] = serde_json::json!({"api_key":"future-top-secret"}); + assert_credential_snapshot_is_read_only(top_level); + + let mut nested = valid_credentials(); + nested["builtin_agents"][0]["future_auth"] = + serde_json::json!({"token":"future-nested-secret"}); + assert_credential_snapshot_is_read_only(nested); +} + +#[test] +fn lossy_credential_values_remain_read_only() { + for alias in ["openai", "openai_compat"] { + let mut kind_alias = valid_credentials(); + kind_alias["builtin_agents"][0]["kind"] = serde_json::json!(alias); + assert_credential_snapshot_is_read_only(kind_alias); + } + + let mut missing_preset = valid_credentials(); + missing_preset["builtin_agents"][0] + .as_object_mut() + .expect("built-in fixture") + .remove("preset"); + assert_credential_snapshot_is_read_only(missing_preset); + + let mut normalized_preset = valid_credentials(); + normalized_preset["builtin_agents"][0]["preset"] = serde_json::json!("doubao"); + normalized_preset["builtin_agents"][0]["model"] = serde_json::json!("ark-code-latest"); + normalized_preset["builtin_agents"][0]["base_url"] = + serde_json::json!("https://ark.cn-beijing.volces.com/api/coding"); + assert_credential_snapshot_is_read_only(normalized_preset); + + let mut unknown_kind = valid_credentials(); + unknown_kind["builtin_agents"][0]["kind"] = serde_json::json!("future-provider"); + assert_credential_snapshot_is_read_only(unknown_kind); + + let mut unknown_image_provider = valid_credentials(); + unknown_image_provider["image_gen_profiles"] = serde_json::json!([{ + "id":"igp-1", + "name":"Future image", + "provider":"future-provider", + "api_key":"future-image-secret", + "model":"future-image", + "base_url":null + }]); + assert_credential_snapshot_is_read_only(unknown_image_provider); + + let mut bad_active = valid_credentials(); + bad_active["active_image_gen_profile_id"] = serde_json::json!("igp-missing"); + assert_credential_snapshot_is_read_only(bad_active); + + let mut implicit_active = valid_credentials(); + implicit_active["image_gen_profiles"] = serde_json::json!([{ + "id":"igp-1", + "name":"Image", + "provider":"openai", + "api_key":"image-secret", + "model":"gpt-image-1", + "base_url":null + }]); + assert_credential_snapshot_is_read_only(implicit_active); + + let mut empty_openverse = valid_credentials(); + empty_openverse["openverse_oauth"] = serde_json::json!({"client_id":"","client_secret":""}); + assert_credential_snapshot_is_read_only(empty_openverse); + + let mut whitespace_openverse = valid_credentials(); + whitespace_openverse["openverse_oauth"] = + serde_json::json!({"client_id":" client ","client_secret":" secret "}); + assert_credential_snapshot_is_read_only(whitespace_openverse); + + let mut duplicate = valid_credentials(); + duplicate["builtin_agents"] = serde_json::json!([ + duplicate["builtin_agents"][0].clone(), + { + "id": "builtin-2", + "preset": "custom", + "display_name": "Current", + "kind": "openai-compat", + "api_key": "sk-current-secret", + "model": "current-model", + "base_url": "https://api.openai.com/v1", + "enabled": true + } + ]); + assert_credential_snapshot_is_read_only(duplicate); +} + +#[test] +fn same_version_unknown_general_settings_remain_read_only() { + let raw = r#"{ + "version":1, + "theme":"light", + "future_setting":{"secret":"must-survive"} + }"#; + let mut state = EditorState::new(); + let mut writes = 0; + let load = load_into_with(&mut state, Some(raw), None, |_, _| { + writes += 1; + true + }); + + assert!(!load.loaded); + assert!(load.unsupported_version); + assert_eq!(state.editor_ui.theme_mode, ThemeMode::Dark); + assert_eq!(writes, 0); + assert!(load.initial_settings_fingerprint(&state).is_none()); + + let mut credential_baseline = load.initial_fingerprint(&state); + state.editor_ui.agent_settings.add_builtin_agent_config( + "Edited", + "sk-edited", + "edited-model", + BuiltinAgentKind::OpenAiCompat, + "https://api.openai.com/v1", + ); + assert!( + save_credentials_if_changed_with(&state, &mut credential_baseline, |_, _| { + writes += 1; + true + }) + .is_none() + ); + assert_eq!(writes, 0); +} + +#[test] +fn general_values_that_would_be_normalized_remain_read_only() { + let too_many_recent = (0..=RECENT_FILE_CAP) + .map(|index| serde_json::json!({"path":format!("/{index}.op"),"modified_at":index})) + .collect::>(); + let cases = vec![ + serde_json::json!({"version":1,"theme":"sepia"}), + serde_json::json!({"version":1,"locale":"future-locale"}), + serde_json::json!({"version":1,"locale":"en"}), + serde_json::json!({"version":1,"locale":"zh"}), + serde_json::json!({"version":1,"mcp_port":80}), + serde_json::json!({"version":1,"recent_files":too_many_recent}), + serde_json::json!({ + "version":1, + "builtin_agents":[{ + "id":"builtin-1","display_name":"Future","kind":"future-provider", + "api_key":"future-secret","model":"future-model", + "base_url":"https://future.example/v1","enabled":true + }] + }), + ]; + + for value in cases { + let raw = serde_json::to_string(&value).expect("encode settings fixture"); + let mut state = EditorState::new(); + let mut writes = 0; + let load = load_into_with(&mut state, Some(&raw), None, |_, _| { + writes += 1; + true + }); + assert!(load.unsupported_version, "fixture={raw}"); + assert!(load.initial_settings_fingerprint(&state).is_none()); + assert_eq!(writes, 0, "fixture={raw}"); + } +} + +#[test] +fn acp_scrub_preserves_unknown_general_fields_and_leaves_snapshot_read_only() { + let raw = r#"{ + "version":1, + "theme":"light", + "connected":[true,true,true,true,true], + "acp_agents":[{"id":"acp-1","command":"acp-secret"}], + "future_setting":{"secret":"future-secret"} + }"#; + let mut state = EditorState::new(); + let mut writes = Vec::new(); + let load = load_into_with(&mut state, Some(raw), None, |key, json| { + writes.push((key.to_string(), json.to_string())); + true + }); + + assert!(!load.loaded); + assert!(load.unsupported_version); + assert_eq!(writes.len(), 1); + assert_eq!(writes[0].0, STORAGE_KEY); + assert!(writes[0].1.contains("future_setting")); + assert!(writes[0].1.contains("future-secret")); + assert!(!writes[0].1.contains("acp_agents")); + assert!(!writes[0].1.contains("acp-secret")); + assert!(!writes[0].1.contains("connected")); + assert!(load.initial_settings_fingerprint(&state).is_none()); +} + +#[test] +fn acp_scrub_preserves_unknown_credential_fields_and_disables_future_writes() { + let mut value = valid_credentials(); + value["acp_agents"] = serde_json::json!([{ + "id":"acp-1", + "command":"acp-secret" + }]); + value["future_credentials"] = serde_json::json!({"api_key":"future-credential-secret"}); + let raw = serde_json::to_string(&value).expect("encode credential fixture"); + let mut state = EditorState::new(); + let mut writes = Vec::new(); + let load = load_into_with(&mut state, None, Some(&raw), |key, json| { + writes.push((key.to_string(), json.to_string())); + true + }); + + assert!(!load.loaded); + assert!(load.unsupported_version); + assert_eq!(writes.len(), 1); + assert_eq!(writes[0].0, CREDENTIAL_STORAGE_KEY); + assert!(writes[0].1.contains("future_credentials")); + assert!(writes[0].1.contains("future-credential-secret")); + assert!(!writes[0].1.contains("acp_agents")); + assert!(!writes[0].1.contains("acp-secret")); + + let mut baseline = load.initial_fingerprint(&state); + state.editor_ui.agent_settings.add_builtin_agent_config( + "Edited", + "sk-edited", + "edited-model", + BuiltinAgentKind::OpenAiCompat, + "https://api.openai.com/v1", + ); + assert!( + save_credentials_if_changed_with(&state, &mut baseline, |key, json| { + writes.push((key.to_string(), json.to_string())); + true + }) + .is_none() + ); + assert_eq!(writes.len(), 1); +} diff --git a/crates/op-host-web/src/web_settings_storage.rs b/crates/op-host-web/src/web_settings_storage.rs new file mode 100644 index 000000000..59abcfb2c --- /dev/null +++ b/crates/op-host-web/src/web_settings_storage.rs @@ -0,0 +1,405 @@ +//! Storage orchestration for browser settings and credential snapshots. + +use super::*; +use crate::web_storage::{ + clear_storage_failure, report_storage_failure, report_unsupported_credential_version, + storage_get, storage_set_checked, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CredentialLoad { + pub(crate) loaded: bool, + pub(crate) write_pending: bool, + pub(super) unsupported_version: bool, + settings_write_disabled: bool, + credential_write_disabled: bool, + pending_credential_json: Option, + pending_settings_json: Option, +} + +impl CredentialLoad { + pub(crate) fn initial_settings_fingerprint(&self, state: &EditorState) -> Option { + (!self.settings_write_disabled).then(|| fingerprint(state)) + } + + pub(crate) fn initial_fingerprint(&self, state: &EditorState) -> CredentialFingerprint { + let mut fingerprint = credential_fingerprint(state); + if self.write_pending { + fingerprint.write_pending = true; + fingerprint.pending_credential_json = self.pending_credential_json.clone(); + fingerprint.pending_settings_json = self.pending_settings_json.clone(); + } + fingerprint.write_disabled = self.credential_write_disabled; + fingerprint + } +} + +pub(crate) fn load_into(state: &mut EditorState) -> CredentialLoad { + let settings_raw = storage_get(STORAGE_KEY); + let credential_raw = storage_get(CREDENTIAL_STORAGE_KEY); + let load = load_into_with( + state, + settings_raw.as_deref(), + credential_raw.as_deref(), + storage_set_checked, + ); + if load.unsupported_version { + report_unsupported_credential_version(); + } + if load.write_pending { + report_storage_failure(); + } + load +} + +pub(super) fn load_into_with( + state: &mut EditorState, + settings_raw: Option<&str>, + credential_raw: Option<&str>, + mut persist: F, +) -> CredentialLoad +where + F: FnMut(&str, &str) -> bool, +{ + let stored = apply_stored_snapshots(state, settings_raw, credential_raw); + let unsupported_settings_version = stored.unsupported_settings_version; + match stored.source { + StoredCredentialSource::Separate => { + let sanitized = stored.sanitized_settings_json; + let settings_saved = sanitized + .as_deref() + .is_none_or(|json| persist(STORAGE_KEY, json)); + CredentialLoad { + loaded: true, + write_pending: !settings_saved, + unsupported_version: unsupported_settings_version, + settings_write_disabled: unsupported_settings_version, + credential_write_disabled: false, + pending_credential_json: None, + pending_settings_json: (!settings_saved).then_some(sanitized).flatten(), + } + } + StoredCredentialSource::SanitizedSeparate => { + let sanitized = stored.sanitized_settings_json; + let canonical_credential = credentials_json(state); + let credential = stored.sanitized_credential_json.or(canonical_credential); + let credential_saved = credential + .as_deref() + .is_some_and(|json| persist(CREDENTIAL_STORAGE_KEY, json)); + let settings_saved = credential_saved + && sanitized + .as_deref() + .is_none_or(|json| persist(STORAGE_KEY, json)); + let write_pending = !credential_saved || !settings_saved; + CredentialLoad { + loaded: true, + write_pending, + unsupported_version: unsupported_settings_version, + settings_write_disabled: unsupported_settings_version, + credential_write_disabled: false, + pending_credential_json: (!credential_saved).then_some(credential).flatten(), + pending_settings_json: (!settings_saved).then_some(sanitized).flatten(), + } + } + StoredCredentialSource::Legacy | StoredCredentialSource::InvalidSeparate => { + if unsupported_settings_version { + let sanitized = stored.sanitized_settings_json; + let settings_saved = sanitized + .as_deref() + .is_none_or(|json| persist(STORAGE_KEY, json)); + return CredentialLoad { + loaded: false, + write_pending: !settings_saved, + unsupported_version: true, + settings_write_disabled: true, + credential_write_disabled: true, + pending_credential_json: None, + pending_settings_json: (!settings_saved).then_some(sanitized).flatten(), + }; + } + let sanitized = stored.sanitized_settings_json; + let credential = credentials_json(state); + let credential_saved = credential + .as_deref() + .is_some_and(|json| persist(CREDENTIAL_STORAGE_KEY, json)); + let settings_saved = credential_saved + && sanitized + .as_deref() + .is_none_or(|json| persist(STORAGE_KEY, json)); + let write_pending = !credential_saved || !settings_saved; + CredentialLoad { + // Both legacy credentials and a healed invalid separate + // snapshot are authoritative mount-time snapshots. Queue the + // latter's empty replacement too, so an opt-in daemon drops + // any stale browser-owned server copy immediately. + loaded: true, + write_pending, + unsupported_version: unsupported_settings_version, + settings_write_disabled: unsupported_settings_version, + credential_write_disabled: false, + pending_credential_json: (!credential_saved).then_some(credential).flatten(), + pending_settings_json: (!settings_saved).then_some(sanitized).flatten(), + } + } + StoredCredentialSource::UnsupportedSeparate + | StoredCredentialSource::UnsupportedSanitizedSeparate => { + let credential = stored.sanitized_credential_json; + let credential_saved = credential + .as_deref() + .is_none_or(|json| persist(CREDENTIAL_STORAGE_KEY, json)); + let sanitized = stored.sanitized_settings_json; + let settings_saved = credential_saved + && sanitized + .as_deref() + .is_none_or(|json| persist(STORAGE_KEY, json)); + CredentialLoad { + loaded: false, + write_pending: !credential_saved || !settings_saved, + unsupported_version: true, + settings_write_disabled: unsupported_settings_version, + credential_write_disabled: true, + pending_credential_json: (!credential_saved).then_some(credential).flatten(), + pending_settings_json: (!settings_saved).then_some(sanitized).flatten(), + } + } + StoredCredentialSource::None => { + let sanitized = stored.sanitized_settings_json; + let settings_saved = sanitized + .as_deref() + .is_none_or(|json| persist(STORAGE_KEY, json)); + CredentialLoad { + loaded: false, + write_pending: !settings_saved, + unsupported_version: unsupported_settings_version, + settings_write_disabled: unsupported_settings_version, + credential_write_disabled: unsupported_settings_version, + pending_credential_json: None, + pending_settings_json: (!settings_saved).then_some(sanitized).flatten(), + } + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum StoredCredentialSource { + None, + Separate, + SanitizedSeparate, + Legacy, + InvalidSeparate, + UnsupportedSeparate, + UnsupportedSanitizedSeparate, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct StoredLoad { + pub(super) source: StoredCredentialSource, + pub(super) sanitize_legacy_settings: bool, + pub(super) unsupported_settings_version: bool, + sanitized_settings_json: Option, + sanitized_credential_json: Option, +} + +pub(super) fn apply_stored_snapshots( + state: &mut EditorState, + settings_raw: Option<&str>, + credential_raw: Option<&str>, +) -> StoredLoad { + let mut sanitize_legacy_settings = false; + let mut migrate_legacy_credentials = false; + let mut sanitized_settings_json = None; + let mut unsupported_settings_version = false; + if let Some(raw) = settings_raw { + let prepared = legacy::prepare(raw); + unsupported_settings_version = prepared.unsupported_version; + sanitize_legacy_settings = prepared.sanitized_raw.is_some(); + migrate_legacy_credentials = prepared.had_legacy_credentials && prepared.payload.is_some(); + sanitized_settings_json = prepared.sanitized_raw; + if let Some(payload) = prepared.payload { + if payload.version == SETTINGS_VERSION { + apply_payload(state, payload); + } + } + } + if let Some(raw) = credential_raw { + let Ok(mut value) = serde_json::from_str::(raw) else { + clear_local_credentials(state); + return StoredLoad { + source: StoredCredentialSource::InvalidSeparate, + sanitize_legacy_settings, + unsupported_settings_version, + sanitized_settings_json, + sanitized_credential_json: None, + }; + }; + let version = value + .as_object() + .and_then(|object| object.get("version")) + .and_then(serde_json::Value::as_u64); + if version != Some(u64::from(CREDENTIAL_PAYLOAD_VERSION)) { + clear_local_credentials(state); + return StoredLoad { + source: StoredCredentialSource::UnsupportedSeparate, + sanitize_legacy_settings, + unsupported_settings_version, + sanitized_settings_json, + sanitized_credential_json: None, + }; + } + let sanitized_credential_json = strip_legacy_acp_credentials(&mut value); + let source = match validation::credential_payload(&value) { + Ok(payload) => { + apply_credential_payload(state, payload); + if sanitized_credential_json.is_some() { + StoredCredentialSource::SanitizedSeparate + } else { + StoredCredentialSource::Separate + } + } + Err(_) => { + clear_local_credentials(state); + if sanitized_credential_json.is_some() { + StoredCredentialSource::UnsupportedSanitizedSeparate + } else { + StoredCredentialSource::UnsupportedSeparate + } + } + }; + return StoredLoad { + source, + sanitize_legacy_settings, + unsupported_settings_version, + sanitized_settings_json, + sanitized_credential_json, + }; + } + StoredLoad { + source: if migrate_legacy_credentials { + StoredCredentialSource::Legacy + } else { + StoredCredentialSource::None + }, + sanitize_legacy_settings, + unsupported_settings_version, + sanitized_settings_json, + sanitized_credential_json: None, + } +} + +fn strip_legacy_acp_credentials(value: &mut serde_json::Value) -> Option { + let object = value.as_object_mut()?; + object.remove("acp_agents")?; + serde_json::to_string(value).ok() +} + +fn clear_local_credentials(state: &mut EditorState) { + apply_credential_payload( + state, + CredentialPayload { + version: CREDENTIAL_PAYLOAD_VERSION, + builtin_agents: Vec::new(), + image_gen_profiles: Vec::new(), + active_image_gen_profile_id: None, + openverse_oauth: None, + }, + ); +} + +pub(crate) fn save_if_changed(state: &EditorState, before: &mut Fingerprint) -> bool { + let saved = save_if_changed_with(state, before, |json| storage_set_checked(STORAGE_KEY, json)); + if saved { + clear_storage_failure(); + } else if fingerprint(state) != *before { + report_storage_failure(); + } + saved +} + +pub(super) fn save_if_changed_with( + state: &EditorState, + before: &mut Fingerprint, + persist: F, +) -> bool +where + F: FnOnce(&str) -> bool, +{ + let next = fingerprint(state); + if next == *before { + return false; + } + let Ok(json) = serde_json::to_string(&to_payload(state)) else { + return false; + }; + if !persist(&json) { + return false; + } + *before = next; + true +} + +pub(crate) fn save_credentials_if_changed( + state: &EditorState, + before: &mut CredentialFingerprint, +) -> Option { + let had_pending_write = before.write_pending; + let saved = save_credentials_if_changed_with(state, before, storage_set_checked); + if saved.is_some() || (had_pending_write && !before.write_pending) { + clear_storage_failure(); + } else if before.write_pending + || (!before.write_disabled && credential_fingerprint(state) != *before) + { + report_storage_failure(); + } + saved +} + +pub(crate) fn credential_migration_pending(before: &CredentialFingerprint) -> bool { + before.write_pending +} + +pub(super) fn save_credentials_if_changed_with( + state: &EditorState, + before: &mut CredentialFingerprint, + mut persist: F, +) -> Option +where + F: FnMut(&str, &str) -> bool, +{ + if before.write_pending && !retry_pending_writes(before, &mut persist) { + return None; + } + if before.write_disabled { + return None; + } + let next = credential_fingerprint(state); + if next == *before { + return None; + } + let json = credentials_json(state)?; + if !persist(CREDENTIAL_STORAGE_KEY, &json) { + return None; + } + *before = next; + Some(json) +} + +fn retry_pending_writes(before: &mut CredentialFingerprint, persist: &mut F) -> bool +where + F: FnMut(&str, &str) -> bool, +{ + if let Some(json) = before.pending_credential_json.clone() { + if !persist(CREDENTIAL_STORAGE_KEY, &json) { + return false; + } + before.pending_credential_json = None; + } + if let Some(json) = before.pending_settings_json.clone() { + if !persist(STORAGE_KEY, &json) { + return false; + } + before.pending_settings_json = None; + } + before.write_pending = + before.pending_credential_json.is_some() || before.pending_settings_json.is_some(); + true +} diff --git a/crates/op-host-web/src/web_settings_tests.rs b/crates/op-host-web/src/web_settings_tests.rs new file mode 100644 index 000000000..eebd74574 --- /dev/null +++ b/crates/op-host-web/src/web_settings_tests.rs @@ -0,0 +1,704 @@ +use super::*; +use op_editor_core::{EditorState, Locale, ThemeMode}; + +#[test] +fn settings_payload_restores_theme_and_locale() { + let payload = r#"{"version":1,"theme":"light","locale":"en-US"}"#; + let mut state = EditorState::new(); + + apply_json(&mut state, payload).expect("settings payload should parse"); + + assert_eq!(state.editor_ui.theme_mode, ThemeMode::Light); + assert_eq!(state.editor_ui.locale, Locale::EnUs); +} + +#[test] +fn fingerprint_changes_when_theme_changes() { + let mut state = EditorState::new(); + let before = fingerprint(&state); + + state.editor_ui.theme_mode = ThemeMode::Light; + + assert_ne!(before, fingerprint(&state)); +} + +#[test] +fn settings_payload_round_trips_recent_files_and_mcp_preferences() { + let mut src = EditorState::new(); + src.editor_ui.theme_mode = ThemeMode::Light; + src.editor_ui.locale = Locale::Ja; + src.editor_ui.agent_settings.mcp_server.port = 4321; + src.editor_ui.agent_settings.mcp_cli_enabled[1] = true; + src.editor_ui.agent_settings.images_advanced_open = true; + src.editor_ui.agent_settings.auto_update_enabled = false; + src.editor_ui.agent_settings.experimental_features_enabled = true; + src.editor_ui.recent_files = vec![ + RecentFile { + path: "/tmp/a.op".into(), + modified_at: 1, + }, + RecentFile { + path: "/tmp/b.op".into(), + modified_at: 2, + }, + ]; + let json = serde_json::to_string(&to_payload(&src)).expect("settings serialize"); + let mut dst = EditorState::new(); + + apply_json(&mut dst, &json).expect("settings payload parses"); + + assert_eq!(dst.editor_ui.theme_mode, ThemeMode::Light); + assert_eq!(dst.editor_ui.locale, Locale::Ja); + assert_eq!(dst.editor_ui.agent_settings.mcp_server.port, 4321); + assert!(dst.editor_ui.agent_settings.mcp_cli_enabled[1]); + assert!(dst.editor_ui.agent_settings.images_advanced_open); + assert!(!dst.editor_ui.agent_settings.auto_update_enabled); + assert!(dst.editor_ui.agent_settings.experimental_features_enabled); + assert_eq!(dst.editor_ui.recent_files.len(), 2); + assert_eq!(dst.editor_ui.recent_files[0].path, "/tmp/a.op"); +} + +#[test] +fn separate_settings_and_credential_payloads_round_trip_their_own_fields() { + let mut src = EditorState::new(); + src.editor_ui.agent_settings.add_builtin_agent_config( + "MiniMax", + "sk-test", + "MiniMax-M2.7", + BuiltinAgentKind::Anthropic, + "https://api.minimaxi.com/anthropic", + ); + let image_profile_id = src.editor_ui.agent_settings.add_image_gen_profile(); + let profile = &mut src.editor_ui.agent_settings.image_gen_profiles[0]; + profile.name = "Gemini Image".into(); + profile.provider = ImageGenProvider::Gemini; + profile.api_key = "image-key".into(); + profile.model = "gemini-image".into(); + assert!(src + .editor_ui + .agent_settings + .set_active_image_gen_profile(&image_profile_id)); + let settings_json = serde_json::to_string(&to_payload(&src)).expect("settings serialize"); + let credential_json = credentials_json(&src).expect("credentials serialize"); + assert!(!settings_json.contains("acp_agents")); + assert!(!credential_json.contains("acp_agents")); + let mut dst = EditorState::new(); + + apply_json(&mut dst, &settings_json).expect("settings payload parses"); + apply_credential_json(&mut dst, &credential_json).expect("credential payload parses"); + + assert_eq!(dst.editor_ui.agent_settings.builtin_agents.len(), 1); + assert_eq!( + dst.editor_ui.agent_settings.builtin_agents[0].display_name, + "MiniMax" + ); + assert_eq!( + dst.editor_ui.agent_settings.builtin_agents[0].preset, + BuiltinAgentPresetKey::MiniMax + ); + assert!(dst.editor_ui.agent_settings.acp_agents.is_empty()); + assert_eq!(dst.editor_ui.agent_settings.image_gen_profiles.len(), 1); + assert_eq!( + dst.editor_ui.agent_settings.image_gen_profiles[0].provider, + ImageGenProvider::Gemini + ); + assert_eq!( + dst.editor_ui + .agent_settings + .active_image_gen_profile_id + .as_deref(), + Some(image_profile_id.as_str()) + ); +} + +#[test] +fn credential_fingerprint_ignores_theme_but_tracks_every_secret_category() { + let mut state = EditorState::new(); + let baseline = credential_fingerprint(&state); + + state.editor_ui.theme_mode = ThemeMode::Light; + assert_eq!(baseline, credential_fingerprint(&state)); + + state.editor_ui.agent_settings.openverse_client_secret = "openverse-secret".into(); + assert_ne!(baseline, credential_fingerprint(&state)); + + let mut state = EditorState::new(); + state.editor_ui.agent_settings.add_builtin_agent_config( + "Private", + "sk-test", + "private-model", + BuiltinAgentKind::OpenAiCompat, + "https://example.test/v1", + ); + assert_ne!(baseline, credential_fingerprint(&state)); + + let mut state = EditorState::new(); + state.editor_ui.agent_settings.add_image_gen_profile(); + state.editor_ui.agent_settings.image_gen_profiles[0].api_key = "image-key".into(); + assert_ne!(baseline, credential_fingerprint(&state)); +} + +#[test] +fn general_settings_fingerprint_ignores_credentials_from_another_tab() { + let mut state = EditorState::new(); + let baseline = fingerprint(&state); + + state.editor_ui.agent_settings.add_builtin_agent_config( + "Private", + "sk-separate-store", + "private-model", + BuiltinAgentKind::OpenAiCompat, + "https://api.openai.com/v1", + ); + state.editor_ui.agent_settings.openverse_client_secret = "openverse".into(); + + assert_eq!(baseline, fingerprint(&state)); +} + +#[test] +fn credential_payload_contains_credentials_but_not_document_or_recent_files() { + let mut state = EditorState::new(); + state.editor_ui.agent_settings.add_builtin_agent_config( + "Private", + "sk-test", + "private-model", + BuiltinAgentKind::OpenAiCompat, + "https://example.test/v1", + ); + state.editor_ui.agent_settings.add_image_gen_profile(); + state.editor_ui.agent_settings.image_gen_profiles[0].api_key = "image-key".into(); + state.editor_ui.agent_settings.openverse_client_id = "openverse-client".into(); + state.editor_ui.agent_settings.openverse_client_secret = "openverse-secret".into(); + state.editor_ui.recent_files.push(RecentFile { + path: "/tmp/must-not-leak.op".into(), + modified_at: 1, + }); + + let json = credentials_json(&state).expect("credential JSON"); + let payload: serde_json::Value = serde_json::from_str(&json).expect("credential payload"); + + assert!(json.contains("sk-test")); + assert!(json.contains("image-key")); + assert!(json.contains("openverse-secret")); + assert!(!json.contains("recent_files")); + assert!(!json.contains("must-not-leak")); + assert!(!json.contains("document")); + assert_eq!(payload["version"], 2); + assert!(payload.get("owner_id").is_none()); +} + +#[test] +fn web_credential_snapshots_omit_acp_configuration_entirely() { + let mut state = EditorState::new(); + let mut env = std::collections::BTreeMap::new(); + env.insert("TOKEN".into(), "acp-env-secret".into()); + state.editor_ui.agent_settings.add_acp_agent_config( + "Local ACP", + op_editor_core::AcpConnectionType::Local, + "/bin/sh", + vec!["-c".into(), "acp-command-secret".into()], + env, + None, + true, + ); + + let json = server_credentials_json(&state).expect("server credential JSON"); + let payload: serde_json::Value = serde_json::from_str(&json).expect("credential payload"); + + assert!(payload.get("acp_agents").is_none()); + assert!(!json.contains("/bin/sh")); + assert!(!json.contains("acp-command-secret")); + assert!(!json.contains("acp-env-secret")); +} + +#[test] +fn failed_local_storage_write_does_not_advance_the_settings_fingerprint() { + let mut state = EditorState::new(); + let mut baseline = fingerprint(&state); + state.editor_ui.theme_mode = ThemeMode::Light; + + assert!(!save_if_changed_with(&state, &mut baseline, |_| false)); + assert_ne!(baseline, fingerprint(&state)); + + assert!(save_if_changed_with(&state, &mut baseline, |_| true)); + assert_eq!(baseline, fingerprint(&state)); +} + +#[test] +fn failed_credential_storage_write_does_not_advance_its_fingerprint() { + let mut state = EditorState::new(); + let mut baseline = credential_fingerprint(&state); + state.editor_ui.agent_settings.openverse_client_secret = "must-retry".into(); + + assert!(save_credentials_if_changed_with(&state, &mut baseline, |_, _| false).is_none()); + assert_ne!(baseline, credential_fingerprint(&state)); + + assert!(save_credentials_if_changed_with(&state, &mut baseline, |_, _| true).is_some()); + assert_eq!(baseline, credential_fingerprint(&state)); +} + +#[test] +fn general_settings_payload_never_contains_credentials() { + let mut state = EditorState::new(); + state.editor_ui.agent_settings.add_builtin_agent_config( + "Private", + "sk-must-not-leak", + "private-model", + BuiltinAgentKind::OpenAiCompat, + "https://api.openai.com/v1", + ); + state.editor_ui.agent_settings.openverse_client_secret = "openverse-secret".into(); + state.editor_ui.agent_settings.add_acp_agent_config( + "Private ACP", + op_editor_core::AcpConnectionType::Remote, + "command-secret", + vec!["--token=args-secret".into()], + std::collections::BTreeMap::from([("TOKEN".into(), "env-secret".into())]), + Some("https://user:url-secret@example.test/acp".into()), + true, + ); + + let json = serde_json::to_string(&to_payload(&state)).unwrap(); + + assert!(!json.contains("sk-must-not-leak")); + assert!(!json.contains("openverse-secret")); + assert!(!json.contains("command-secret")); + assert!(!json.contains("args-secret")); + assert!(!json.contains("env-secret")); + assert!(!json.contains("url-secret")); + assert!(!json.contains("Private ACP")); + assert!(!json.contains("acp_agents")); + assert!(!json.contains("\"openverse_oauth\"")); + assert!(!json.contains("\"builtin_agents\"")); + assert!(!json.contains("\"image_gen_profiles\"")); + assert!(!json.contains("\"active_image_gen_profile_id\"")); +} + +#[test] +fn an_empty_credential_snapshot_round_trips_as_authoritative_empty_state() { + let source = EditorState::new(); + let json = credentials_json(&source).unwrap(); + let mut target = EditorState::new(); + target.editor_ui.agent_settings.add_builtin_agent_config( + "Stale", + "sk-stale", + "stale-model", + BuiltinAgentKind::OpenAiCompat, + "https://api.openai.com/v1", + ); + + apply_credential_json(&mut target, &json).unwrap(); + + assert!(target.editor_ui.agent_settings.builtin_agents.is_empty()); +} + +#[test] +fn unsupported_settings_version_is_not_treated_as_a_loaded_snapshot() { + let mut state = EditorState::new(); + + assert!(apply_json(&mut state, r#"{"version":999}"#).is_err()); + assert!(apply_credential_json(&mut state, r#"{"version":999}"#).is_err()); +} + +#[test] +fn future_credential_snapshot_fails_closed_without_overwriting_the_raw_value() { + let future = r#"{ + "version": 3, + "builtin_agents": [{ + "id": "future-agent", + "api_key": "future-secret-must-survive-downgrade" + }] + }"#; + let mut state = EditorState::new(); + let mut writes = 0; + + let load = load_into_with(&mut state, None, Some(future), |_, _| { + writes += 1; + true + }); + + assert!(!load.loaded); + assert!(!load.write_pending); + assert_eq!(writes, 0, "an older client must preserve a future snapshot"); + assert!(state.editor_ui.agent_settings.builtin_agents.is_empty()); +} + +#[test] +fn future_credential_snapshot_remains_read_only_after_a_provider_edit() { + let future = r#"{ + "version": 3, + "builtin_agents": [{ + "id": "future-agent", + "api_key": "future-secret-must-survive-downgrade" + }] + }"#; + let mut state = EditorState::new(); + let load = load_into_with(&mut state, None, Some(future), |_, _| true); + let mut baseline = load.initial_fingerprint(&state); + + state.editor_ui.agent_settings.add_builtin_agent_config( + "Edited", + "sk-edited", + "edited-model", + BuiltinAgentKind::OpenAiCompat, + "https://api.openai.com/v1", + ); + let mut writes = 0; + let saved = save_credentials_if_changed_with(&state, &mut baseline, |key, _| { + if key == CREDENTIAL_STORAGE_KEY { + writes += 1; + } + true + }); + + assert!(saved.is_none()); + assert_eq!( + writes, 0, + "an older client must not overwrite a future credential snapshot" + ); +} + +#[test] +fn future_general_settings_snapshot_is_never_sanitized_by_an_older_client() { + let future = r#"{ + "version": 2, + "theme": "light", + "builtin_agents": [{ + "id": "future-agent", + "api_key": "future-general-secret-must-survive-downgrade" + }] + }"#; + let mut state = EditorState::new(); + let mut writes = 0; + + let load = load_into_with(&mut state, Some(future), None, |_, _| { + writes += 1; + true + }); + + assert!(!load.loaded); + assert!(load.unsupported_version); + assert_eq!(writes, 0, "an older client must preserve future settings"); + assert_eq!(state.editor_ui.theme_mode, ThemeMode::Dark); + assert!(state.editor_ui.agent_settings.builtin_agents.is_empty()); +} + +#[test] +fn future_general_settings_remain_read_only_after_a_theme_change() { + let future = r#"{ + "version": 2, + "theme": "dark", + "future_field": "must-survive-downgrade" + }"#; + let mut state = EditorState::new(); + let load = load_into_with(&mut state, Some(future), None, |_, _| true); + let mut baseline = load.initial_settings_fingerprint(&state); + + state.editor_ui.theme_mode = ThemeMode::Light; + let mut writes = 0; + if let Some(baseline) = baseline.as_mut() { + let _ = save_if_changed_with(&state, baseline, |_| { + writes += 1; + true + }); + } + + assert_eq!( + writes, 0, + "a mounted older client must not overwrite v2 settings" + ); +} + +#[test] +fn corrupt_separate_snapshot_never_revives_legacy_credentials() { + let legacy = r#"{ + "version": 1, + "theme": "light", + "builtin_agents": [{ + "id": "builtin-1", + "preset": "custom", + "display_name": "Legacy", + "kind": "openai-compat", + "api_key": "sk-legacy", + "model": "legacy-model", + "base_url": "https://api.openai.com/v1", + "enabled": true + }] + }"#; + let mut state = EditorState::new(); + + let loaded = apply_stored_snapshots(&mut state, Some(legacy), Some("{corrupt")); + + assert_eq!(loaded.source, StoredCredentialSource::InvalidSeparate); + assert!(loaded.sanitize_legacy_settings); + assert_eq!(state.editor_ui.theme_mode, ThemeMode::Light); + assert!(state.editor_ui.agent_settings.builtin_agents.is_empty()); +} + +#[test] +fn corrupt_separate_snapshot_queues_its_persisted_empty_replacement_for_server_sync() { + let mut state = EditorState::new(); + let mut writes = Vec::new(); + + let load = load_into_with(&mut state, None, Some("{corrupt"), |key, json| { + writes.push((key.to_string(), json.to_string())); + true + }); + + assert!( + load.loaded, + "the healed empty snapshot must be synced at mount" + ); + assert!(!load.write_pending); + let credential_write = writes + .iter() + .find(|(key, _)| key == CREDENTIAL_STORAGE_KEY) + .expect("the corrupt credential snapshot is replaced"); + let value: serde_json::Value = + serde_json::from_str(&credential_write.1).expect("replacement is valid JSON"); + assert_eq!(value["builtin_agents"], serde_json::json!([])); + assert!(value.get("acp_agents").is_none()); + assert_eq!(value["image_gen_profiles"], serde_json::json!([])); +} + +#[test] +fn valid_separate_snapshot_loads_without_overwriting_future_general_settings() { + let credentials = r#"{ + "version": 2, + "builtin_agents": [{ + "id": "builtin-1", + "preset": "custom", + "display_name": "Current", + "kind": "openai-compat", + "api_key": "sk-current", + "model": "current-model", + "base_url": "https://api.openai.com/v1", + "enabled": true + }], + "image_gen_profiles": [], + "active_image_gen_profile_id": null, + "openverse_oauth": null + }"#; + let mut state = EditorState::new(); + + let loaded = apply_stored_snapshots( + &mut state, + Some(r#"{"version":999,"theme":"light"}"#), + Some(credentials), + ); + + assert_eq!(loaded.source, StoredCredentialSource::Separate); + assert!(!loaded.sanitize_legacy_settings); + assert_eq!(state.editor_ui.theme_mode, ThemeMode::Dark); + assert_eq!(state.editor_ui.agent_settings.builtin_agents.len(), 1); + assert_eq!( + state.editor_ui.agent_settings.builtin_agents[0].api_key, + "sk-current" + ); +} + +#[test] +fn valid_empty_separate_snapshot_is_authoritative() { + let mut state = EditorState::new(); + state.editor_ui.agent_settings.add_builtin_agent_config( + "Stale", + "sk-stale", + "stale-model", + BuiltinAgentKind::OpenAiCompat, + "https://api.openai.com/v1", + ); + let empty = r#"{ + "version": 2, + "builtin_agents": [], + "image_gen_profiles": [], + "active_image_gen_profile_id": null, + "openverse_oauth": null + }"#; + + let loaded = apply_stored_snapshots(&mut state, None, Some(empty)); + + assert_eq!(loaded.source, StoredCredentialSource::Separate); + assert!(state.editor_ui.agent_settings.builtin_agents.is_empty()); +} + +#[test] +fn unknown_legacy_credential_records_are_preserved_read_only() { + let legacy = r#"{ + "version": 1, + "theme": "light", + "builtin_agents": [{ + "id": "builtin-invalid", + "display_name": "Invalid but secret-bearing", + "kind": "unsupported-provider", + "api_key": "sk-must-be-sanitized", + "model": "unused", + "base_url": "https://example.test/v1", + "enabled": true + }] + }"#; + let mut state = EditorState::new(); + + let loaded = apply_stored_snapshots(&mut state, Some(legacy), None); + + assert_eq!(loaded.source, StoredCredentialSource::None); + assert!(loaded.unsupported_settings_version); + assert!(!loaded.sanitize_legacy_settings); + assert!(state.editor_ui.agent_settings.builtin_agents.is_empty()); + assert_eq!(state.editor_ui.theme_mode, ThemeMode::Dark); +} + +#[test] +fn malformed_legacy_credential_records_are_preserved_read_only() { + let legacy = r#"{ + "version": 1, + "theme": "light", + "builtin_agents": [{ + "api_key": "sk-malformed-must-be-sanitized", + "enabled": "not-a-boolean" + }] + }"#; + let mut state = EditorState::new(); + + let loaded = apply_stored_snapshots(&mut state, Some(legacy), None); + + assert_eq!(loaded.source, StoredCredentialSource::None); + assert!(loaded.unsupported_settings_version); + assert!(!loaded.sanitize_legacy_settings); + assert_eq!(state.editor_ui.theme_mode, ThemeMode::Dark); +} + +#[test] +fn unrelated_invalid_legacy_field_blocks_credential_migration_without_data_loss() { + let legacy = r#"{ + "version": 1, + "theme": "light", + "mcp_cli_enabled": [true], + "builtin_agents": [{ + "id": "builtin-1", + "preset": "custom", + "display_name": "Legacy", + "kind": "openai-compat", + "api_key": "sk-must-survive-invalid-general-field", + "model": "legacy-model", + "base_url": "https://api.openai.com/v1", + "enabled": true + }] + }"#; + let mut state = EditorState::new(); + let mut writes = Vec::new(); + + let load = load_into_with(&mut state, Some(legacy), None, |key, json| { + writes.push((key.to_string(), json.to_string())); + true + }); + + assert!(!load.loaded); + assert!(load.unsupported_version); + assert!( + writes.is_empty(), + "migration must preserve the raw snapshot when unrelated fields are invalid" + ); + assert!(load.initial_settings_fingerprint(&state).is_none()); + + let mut credential_baseline = load.initial_fingerprint(&state); + state.editor_ui.agent_settings.add_builtin_agent_config( + "Edited", + "sk-edited", + "edited-model", + BuiltinAgentKind::OpenAiCompat, + "https://api.openai.com/v1", + ); + assert!( + save_credentials_if_changed_with(&state, &mut credential_baseline, |key, json| { + writes.push((key.to_string(), json.to_string())); + true + }) + .is_none() + ); + assert!(writes.is_empty()); +} + +#[test] +fn failed_legacy_credential_write_stays_loaded_and_retries_before_sanitizing() { + let legacy = r#"{ + "version": 1, + "theme": "light", + "builtin_agents": [{ + "id": "builtin-1", + "preset": "custom", + "display_name": "Legacy", + "kind": "openai-compat", + "api_key": "sk-retry-migration", + "model": "legacy-model", + "base_url": "https://api.openai.com/v1", + "enabled": true + }] + }"#; + let mut state = EditorState::new(); + let mut first_writes = Vec::new(); + + let load = load_into_with(&mut state, Some(legacy), None, |key, json| { + first_writes.push((key.to_string(), json.to_string())); + false + }); + + assert!(load.loaded); + assert!(load.write_pending); + assert_eq!(first_writes.len(), 1); + assert_eq!(first_writes[0].0, CREDENTIAL_STORAGE_KEY); + assert!(first_writes[0].1.contains("sk-retry-migration")); + + let mut baseline = load.initial_fingerprint(&state); + assert_ne!(baseline, credential_fingerprint(&state)); + let mut retry_writes = Vec::new(); + let saved = save_credentials_if_changed_with(&state, &mut baseline, |key, json| { + retry_writes.push((key.to_string(), json.to_string())); + true + }); + + // The mount-time `loaded` signal already authorizes the initial daemon + // sync. Retrying local persistence must not emit a second sync payload. + assert!(saved.is_none()); + assert_eq!(retry_writes.len(), 2); + assert_eq!(retry_writes[0].0, CREDENTIAL_STORAGE_KEY); + assert_eq!(retry_writes[1].0, STORAGE_KEY); + assert!(!retry_writes[1].1.contains("sk-retry-migration")); + assert_eq!(baseline, credential_fingerprint(&state)); +} + +#[test] +fn pending_legacy_migration_blocks_an_ordinary_settings_write() { + let legacy = r#"{ + "version": 1, + "theme": "light", + "builtin_agents": [{ + "id": "builtin-1", + "preset": "custom", + "display_name": "Legacy", + "kind": "openai-compat", + "api_key": "sk-only-durable-copy", + "model": "legacy-model", + "base_url": "https://api.openai.com/v1", + "enabled": true + }] + }"#; + let mut state = EditorState::new(); + let load = load_into_with(&mut state, Some(legacy), None, |_, _| false); + let mut credential_baseline = load.initial_fingerprint(&state); + let mut settings_baseline = fingerprint(&state); + state.editor_ui.theme_mode = ThemeMode::Dark; + + assert!( + save_credentials_if_changed_with(&state, &mut credential_baseline, |_, _| false).is_none() + ); + let mut general_write_attempted = false; + if !credential_migration_pending(&credential_baseline) { + let _ = save_if_changed_with(&state, &mut settings_baseline, |_| { + general_write_attempted = true; + true + }); + } + + assert!(!general_write_attempted); + assert_ne!(settings_baseline, fingerprint(&state)); +} diff --git a/crates/op-host-web/src/web_settings_validation.rs b/crates/op-host-web/src/web_settings_validation.rs new file mode 100644 index 000000000..ea0d9c33e --- /dev/null +++ b/crates/op-host-web/src/web_settings_validation.rs @@ -0,0 +1,249 @@ +//! Lossless validation for browser-owned settings snapshots. +//! +//! Serde's default forward-compatible behavior ignores unknown object fields, +//! while the settings applicators also normalize or filter several values. +//! That is convenient for native best-effort loading, but unsafe for a browser +//! store: a later edit would rewrite the snapshot and could silently delete a +//! newer client's data or secret. Validate the raw value before applying it so +//! incompatible same-version snapshots can stay untouched and read-only. + +use super::*; + +pub(super) fn settings_payload(value: &serde_json::Value) -> Result { + let object = value + .as_object() + .ok_or_else(|| "browser settings must be an object".to_string())?; + validate_known_fields( + object, + &[ + "version", + "theme", + "locale", + "mcp_port", + "mcp_cli_enabled", + "images_advanced_open", + "openverse_oauth", + "auto_update_enabled", + "experimental_features_enabled", + "builtin_agents", + "image_gen_profiles", + "active_image_gen_profile_id", + "recent_files", + ], + "browser settings", + )?; + validate_nested_fields(object)?; + let payload: SettingsPayload = serde_json::from_value(value.clone()) + .map_err(|_| "invalid browser settings schema".to_string())?; + if payload.version != SETTINGS_VERSION { + return Err("unsupported browser settings version".into()); + } + validate_general_semantics(&payload)?; + validate_credential_semantics( + payload.builtin_agents.as_deref().unwrap_or_default(), + payload.image_gen_profiles.as_deref().unwrap_or_default(), + payload.active_image_gen_profile_id.as_deref(), + payload.openverse_oauth.as_ref(), + )?; + Ok(payload) +} + +pub(super) fn credential_payload(value: &serde_json::Value) -> Result { + let object = value + .as_object() + .ok_or_else(|| "browser credentials must be an object".to_string())?; + validate_known_fields( + object, + &[ + "version", + "builtin_agents", + "image_gen_profiles", + "active_image_gen_profile_id", + "openverse_oauth", + ], + "browser credentials", + )?; + validate_nested_fields(object)?; + let payload: CredentialPayload = serde_json::from_value(value.clone()) + .map_err(|_| "invalid browser credential schema".to_string())?; + if payload.version != CREDENTIAL_PAYLOAD_VERSION { + return Err("unsupported browser credential version".into()); + } + validate_credential_semantics( + &payload.builtin_agents, + &payload.image_gen_profiles, + payload.active_image_gen_profile_id.as_deref(), + payload.openverse_oauth.as_ref(), + )?; + Ok(payload) +} + +fn validate_nested_fields( + object: &serde_json::Map, +) -> Result<(), String> { + validate_optional_object_fields( + object.get("openverse_oauth"), + &["client_id", "client_secret"], + "Openverse credentials", + )?; + validate_array_object_fields( + object.get("builtin_agents"), + &[ + "id", + "preset", + "display_name", + "kind", + "api_key", + "model", + "base_url", + "enabled", + ], + "built-in agent", + )?; + validate_array_object_fields( + object.get("image_gen_profiles"), + &["id", "name", "provider", "api_key", "model", "base_url"], + "image profile", + )?; + validate_array_object_fields( + object.get("recent_files"), + &["path", "modified_at"], + "recent file", + ) +} + +fn validate_optional_object_fields( + value: Option<&serde_json::Value>, + allowed: &[&str], + context: &str, +) -> Result<(), String> { + if let Some(value) = value { + if value.is_null() { + return Ok(()); + } + let object = value + .as_object() + .ok_or_else(|| format!("invalid {context} schema"))?; + validate_known_fields(object, allowed, context)?; + } + Ok(()) +} + +fn validate_array_object_fields( + value: Option<&serde_json::Value>, + allowed: &[&str], + context: &str, +) -> Result<(), String> { + let Some(value) = value else { + return Ok(()); + }; + if value.is_null() { + return Ok(()); + } + let entries = value + .as_array() + .ok_or_else(|| format!("invalid {context} list"))?; + for entry in entries { + let object = entry + .as_object() + .ok_or_else(|| format!("invalid {context} entry"))?; + validate_known_fields(object, allowed, context)?; + } + Ok(()) +} + +fn validate_known_fields( + object: &serde_json::Map, + allowed: &[&str], + context: &str, +) -> Result<(), String> { + if object + .keys() + .any(|field| !allowed.contains(&field.as_str())) + { + return Err(format!("unknown field in {context}")); + } + Ok(()) +} + +fn validate_general_semantics(payload: &SettingsPayload) -> Result<(), String> { + if payload + .theme + .as_deref() + .is_some_and(|theme| !matches!(theme, "dark" | "light")) + { + return Err("unknown browser theme".into()); + } + if payload.locale.as_deref().is_some_and(|saved| { + str_to_locale(saved).is_none_or(|locale| locale_to_str(locale) != saved) + }) { + return Err("unknown browser locale".into()); + } + if payload.mcp_port.is_some_and(|port| port < 1024) { + return Err("browser MCP port would be normalized".into()); + } + if payload + .recent_files + .as_ref() + .is_some_and(|recent| recent.len() > RECENT_FILE_CAP) + { + return Err("browser recent-file list would be truncated".into()); + } + Ok(()) +} + +fn validate_credential_semantics( + builtin_agents: &[BuiltinAgentPayload], + image_profiles: &[ImageGenProfilePayload], + active_image_profile: Option<&str>, + openverse: Option<&OpenverseOAuthPayload>, +) -> Result<(), String> { + let mut parsed_agents = Vec::with_capacity(builtin_agents.len()); + for payload in builtin_agents { + if !matches!(payload.kind.as_str(), "anthropic" | "openai-compat") { + return Err("unknown built-in agent kind".into()); + } + let parsed = builtin_agent_from_payload(payload.clone()) + .ok_or_else(|| "unknown built-in agent kind".to_string())?; + let saved = payload + .preset + .as_deref() + .and_then(BuiltinAgentPresetKey::from_str) + .ok_or_else(|| "missing or unknown built-in agent preset".to_string())?; + if parsed.preset != saved { + return Err("built-in agent preset would be normalized".into()); + } + parsed_agents.push(parsed); + } + if dedupe_builtin_agents(parsed_agents.clone()).len() != parsed_agents.len() { + return Err("duplicate built-in agents would be removed".into()); + } + + for profile in image_profiles { + if !matches!( + profile.provider.as_str(), + "openai" | "gemini" | "replicate" | "custom" + ) { + return Err("unknown image generation provider".into()); + } + image_gen_profile_from_payload(profile.clone()) + .ok_or_else(|| "unknown image generation provider".to_string())?; + } + if active_image_profile + .is_some_and(|active| !image_profiles.iter().any(|profile| profile.id == active)) + { + return Err("active image profile would be replaced".into()); + } + if !image_profiles.is_empty() && active_image_profile.is_none() { + return Err("active image profile would be selected implicitly".into()); + } + if let Some(openverse) = openverse { + if openverse.client_id.trim() != openverse.client_id + || openverse.client_secret.trim() != openverse.client_secret + || (openverse.client_id.is_empty() && openverse.client_secret.is_empty()) + { + return Err("Openverse credentials would be normalized".into()); + } + } + Ok(()) +} diff --git a/crates/op-host-web/src/web_storage.rs b/crates/op-host-web/src/web_storage.rs new file mode 100644 index 000000000..2c11f8e73 --- /dev/null +++ b/crates/op-host-web/src/web_storage.rs @@ -0,0 +1,56 @@ +//! Thin, status-aware localStorage adapter for browser-host preferences. + +#[cfg(target_arch = "wasm32")] +pub(crate) fn storage_get(key: &str) -> Option { + web_sys::window() + .and_then(|window| window.local_storage().ok().flatten()) + .and_then(|storage| storage.get_item(key).ok().flatten()) +} + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn storage_get(_key: &str) -> Option { + None +} + +#[cfg(target_arch = "wasm32")] +pub(crate) fn storage_set_checked(key: &str, value: &str) -> bool { + if let Some(storage) = + web_sys::window().and_then(|window| window.local_storage().ok().flatten()) + { + return storage.set_item(key, value).is_ok(); + } + false +} + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn storage_set_checked(_key: &str, _value: &str) -> bool { + false +} + +pub(crate) fn report_storage_failure() { + #[cfg(target_arch = "wasm32")] + STORAGE_FAILURE_REPORTED.with(|reported| { + if !reported.replace(true) { + web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str( + "OpenPencil could not persist browser settings to localStorage", + )); + } + }); +} + +pub(crate) fn report_unsupported_credential_version() { + #[cfg(target_arch = "wasm32")] + web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str( + "OpenPencil found newer or incompatible browser settings; they were left unchanged", + )); +} + +pub(crate) fn clear_storage_failure() { + #[cfg(target_arch = "wasm32")] + STORAGE_FAILURE_REPORTED.with(|reported| reported.set(false)); +} + +#[cfg(target_arch = "wasm32")] +thread_local! { + static STORAGE_FAILURE_REPORTED: std::cell::Cell = const { std::cell::Cell::new(false) }; +} diff --git a/crates/op-host-web/src/widget_host/agent_settings_press_tests.rs b/crates/op-host-web/src/widget_host/agent_settings_press_tests.rs index 5419db707..4d069cf67 100644 --- a/crates/op-host-web/src/widget_host/agent_settings_press_tests.rs +++ b/crates/op-host-web/src/widget_host/agent_settings_press_tests.rs @@ -1,7 +1,7 @@ use super::WidgetHost; use op_editor_core::agent_settings::{ AgentSettingsTab, BuiltinAgentField, ImageGenField, ImageGenProvider, ImageTestStatus, - ProviderConnectPhase, SettingsFocus, + SettingsFocus, }; use op_editor_core::{AgentProvider, AgentSettingsButton, ButtonPressTarget, PencilCursorStyle}; use op_editor_ui::widgets::agent_settings_panel::{AgentSettingsHit, AgentSettingsPanel}; @@ -108,95 +108,6 @@ fn web_agent_settings_hides_mcp_tab_from_sidebar() { ); } -#[test] -fn web_provider_connect_response_applies_real_models() { - let mut host = WidgetHost::new(); - host.editor_state - .editor_ui - .agent_settings - .begin_provider_connect(AgentProvider::CodexCli); - let response = serde_json::json!({ - "ok": true, - "provider": "codex", - "connected": true, - "models": [ - { "value": "gpt-5.5", "displayName": "GPT-5.5" } - ], - "connectionInfo": "Connected via Codex CLI", - "version": "codex 1.2.3" - }) - .to_string(); - - assert!(crate::web_agent_connect::apply_provider_connect_response( - &mut host.editor_state, - AgentProvider::CodexCli, - &response - )); - - let settings = &host.editor_state.editor_ui.agent_settings; - assert!(settings.provider_verified_connected(AgentProvider::CodexCli)); - assert!(host - .editor_state - .chat - .available_models - .iter() - .any(|m| m.provider == AgentProvider::CodexCli && m.value == "gpt-5.5")); -} - -#[test] -fn web_provider_connect_response_without_models_is_failure() { - let mut host = WidgetHost::new(); - host.editor_state - .chat - .discovered_models - .push(op_editor_core::ModelEntry::new( - AgentProvider::CodexCli, - "stale-gpt", - "Stale GPT", - )); - host.editor_state - .editor_ui - .agent_settings - .begin_provider_connect(AgentProvider::CodexCli); - let response = serde_json::json!({ - "ok": true, - "provider": "codex", - "connected": true, - "models": [], - "connectionInfo": "Connected via Codex CLI", - "version": "codex 1.2.3" - }) - .to_string(); - - assert!(crate::web_agent_connect::apply_provider_connect_response( - &mut host.editor_state, - AgentProvider::CodexCli, - &response - )); - - let settings = &host.editor_state.editor_ui.agent_settings; - assert!(!settings.provider_verified_connected(AgentProvider::CodexCli)); - let idx = - op_editor_core::agent_settings::AgentSettings::provider_index(AgentProvider::CodexCli); - assert_eq!( - settings.provider_connection[idx].phase, - ProviderConnectPhase::Error - ); - assert!(settings.provider_connection[idx] - .error - .as_deref() - .is_some_and(|error| error.contains("No models found"))); - assert!( - !host - .editor_state - .chat - .available_models - .iter() - .any(|m| m.provider == AgentProvider::CodexCli), - "stale Codex models must not become selectable after a failed connect" - ); -} - #[test] fn toggling_builtin_kind_commits_focused_api_key_draft() { let mut host = WidgetHost::new(); diff --git a/crates/op-host-web/src/widget_host/group_ops.rs b/crates/op-host-web/src/widget_host/group_ops.rs index 91b385514..c645c7e1a 100644 --- a/crates/op-host-web/src/widget_host/group_ops.rs +++ b/crates/op-host-web/src/widget_host/group_ops.rs @@ -184,9 +184,7 @@ fn trace_grid_contours( let mut points = vec![start]; while current != start { points.push(current); - let Some(next_index) = edges.iter().position(|(from, _)| *from == current) else { - return None; - }; + let next_index = edges.iter().position(|(from, _)| *from == current)?; let (_, next) = edges.swap_remove(next_index); current = next; } diff --git a/crates/op-host-web/tests/web_mount_event_wiring.rs b/crates/op-host-web/tests/web_mount_event_wiring.rs index 1f3d586e2..463c43050 100644 --- a/crates/op-host-web/tests/web_mount_event_wiring.rs +++ b/crates/op-host-web/tests/web_mount_event_wiring.rs @@ -65,3 +65,104 @@ fn canvaskit_mount_does_not_panic_if_a11y_mirror_borrow_is_busy() { "CanvasKit a11y mirror listener setup must not force-borrow the shared host" ); } + +#[test] +fn canvaskit_mount_loads_browser_settings_before_fingerprints_and_first_repaint() { + let source = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/canvaskit.rs")) + .expect("canvaskit source is readable"); + let host = source.find("WidgetHost::new()").expect("host construction"); + let load = source[host..] + .find("web_settings::load_into") + .map(|idx| host + idx) + .expect("browser settings load"); + let fingerprint = source[load..] + .find("initial_settings_fingerprint") + .map(|idx| load + idx) + .expect("settings fingerprint"); + let first_repaint = source[fingerprint..] + .find("repaint();") + .map(|idx| fingerprint + idx) + .expect("first repaint"); + + assert!(host < load && load < fingerprint && fingerprint < first_repaint); +} + +#[test] +fn canvaskit_repaint_persists_local_settings_and_syncs_only_credential_changes() { + let source = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/canvaskit.rs")) + .expect("canvaskit source is readable"); + let repaint_start = source.find("impl CkInner").expect("CkInner implementation"); + let repaint_end = source[repaint_start..] + .find("fn sync_a11y") + .map(|idx| repaint_start + idx) + .expect("end of repaint method"); + let repaint = &source[repaint_start..repaint_end]; + + assert!(repaint.contains("web_settings::save_if_changed")); + assert!(repaint.contains("web_settings::save_credentials_if_changed")); + assert!(repaint.contains("web_settings::credential_migration_pending")); + assert!(repaint.contains("web_credential_sync::credential_changed")); + + let credential_save = repaint + .find("web_settings::save_credentials_if_changed") + .expect("credential save"); + let general_save = repaint + .find("web_settings::save_if_changed") + .expect("general settings save"); + assert!( + credential_save < general_save, + "migration must secure credentials before ordinary settings can overwrite the legacy key" + ); + + let install = source + .find("repaint_coalescer::install") + .expect("repaint coalescer installation"); + let sync_start = source + .find("web_credential_sync::start") + .expect("credential policy discovery"); + assert!( + sync_start < install, + "credential sync state is reset before repaint callbacks can queue changes" + ); +} + +#[test] +fn canvaskit_mount_queues_an_initial_snapshot_only_when_local_credentials_exist() { + let source = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/canvaskit.rs")) + .expect("canvaskit source is readable"); + let load = source + .find("let credential_load = crate::web_settings::load_into") + .expect("mount records whether an independent credential snapshot loaded"); + let conditional = source[load..] + .find("let initial_credential_json = credential_load") + .map(|index| load + index) + .expect("credential snapshot presence gates the initial server sync"); + let policy = source[conditional..] + .find("web_credential_sync::start") + .map(|index| conditional + index) + .expect("policy discovery resets state before the initial snapshot is queued"); + let changed = source[policy..] + .find("web_credential_sync::credential_changed") + .map(|index| policy + index) + .expect("an existing credential snapshot queues after policy discovery starts"); + + assert!(load < conditional && conditional < policy && policy < changed); +} + +#[test] +fn credential_status_requests_use_a_finite_timeout() { + let source = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/live_sync.rs")) + .expect("live sync source is readable"); + let get_with_status = source + .split("pub fn get_with_status") + .nth(1) + .and_then(|body| body.split("pub fn post_json").next()) + .expect("status-aware GET implementation"); + let post_with_status = source + .split("pub fn post_json_with_status") + .nth(1) + .expect("status-aware POST implementation"); + + assert!(get_with_status.contains("xhr.set_timeout(STATUS_REQUEST_TIMEOUT_MS)")); + assert!(post_with_status.contains("xhr.set_timeout(STATUS_REQUEST_TIMEOUT_MS)")); +} diff --git a/packages/bun.lock b/packages/bun.lock index f36648fb6..6a0491645 100644 --- a/packages/bun.lock +++ b/packages/bun.lock @@ -14,7 +14,7 @@ }, "op-web-sdk": { "name": "@zseven-w/op-web-sdk", - "version": "0.8.0", + "version": "0.8.1", "devDependencies": { "jsdom": "^25.0.0", "tsup": "^8.0.0", @@ -24,7 +24,7 @@ }, "op-web-sdk-react": { "name": "@zseven-w/op-web-sdk-react", - "version": "0.8.0", + "version": "0.8.1", "dependencies": { "@zseven-w/op-web-sdk": "workspace:*", }, @@ -46,7 +46,7 @@ }, "op-web-sdk-vue": { "name": "@zseven-w/op-web-sdk-vue", - "version": "0.8.0", + "version": "0.8.1", "dependencies": { "@zseven-w/op-web-sdk": "workspace:*", }, diff --git a/packages/op-web-sdk-react/src/index.ts b/packages/op-web-sdk-react/src/index.ts index 40fe92500..d1a37d900 100644 --- a/packages/op-web-sdk-react/src/index.ts +++ b/packages/op-web-sdk-react/src/index.ts @@ -1,4 +1,4 @@ -export const VERSION = '0.8.0'; +export const VERSION = '0.8.1'; export { DesignProvider, useViewer } from './use-viewer.js'; export { useDocument, useViewport, useActivePage } from './hooks.js'; export { DesignView, type DesignViewProps } from './design-view.js'; diff --git a/packages/op-web-sdk-vue/src/index.ts b/packages/op-web-sdk-vue/src/index.ts index 2d3f29a71..a981636fa 100644 --- a/packages/op-web-sdk-vue/src/index.ts +++ b/packages/op-web-sdk-vue/src/index.ts @@ -1,4 +1,4 @@ -export const VERSION = '0.8.0'; +export const VERSION = '0.8.1'; export { viewerKey } from './injection.js'; export { provideViewer, provideViewerRef, useViewer } from './use-viewer.js'; export { useDocument, useViewport, useActivePage } from './composables.js'; diff --git a/packages/op-web-sdk/src/index.ts b/packages/op-web-sdk/src/index.ts index 5b47652ff..dce4d3b56 100644 --- a/packages/op-web-sdk/src/index.ts +++ b/packages/op-web-sdk/src/index.ts @@ -1,5 +1,5 @@ // Public entry for the OpenPencil read-only web SDK core. -export const VERSION = '0.8.0'; +export const VERSION = '0.8.1'; export { createViewer, OpViewer } from './viewer.js'; export type { Viewport, CreateViewerOptions, PenDocument, PenPage } from './types.js'; export type { ViewerEvent } from './events.js'; diff --git a/packages/package.json b/packages/package.json index 629800920..92d012c95 100644 --- a/packages/package.json +++ b/packages/package.json @@ -1,6 +1,6 @@ { "name": "@zseven-w/openpencil-packages", - "version": "0.8.0", + "version": "0.8.1", "private": true, "description": "Web SDK workspace for OpenPencil (the Rust product lives in ../crates). Hosts the wasm-backed op-web-sdk viewer family plus the JS build glue that feeds the Rust web target.", "author": { diff --git a/scripts/bundle-macos.sh b/scripts/bundle-macos.sh index b555f82b4..df96650f5 100755 --- a/scripts/bundle-macos.sh +++ b/scripts/bundle-macos.sh @@ -29,7 +29,7 @@ # first). # # CI / cross-build controls (all optional, default = local behavior): -# OPENPENCIL_VERSION CFBundleShortVersionString (default 0.8.0) +# OPENPENCIL_VERSION CFBundleShortVersionString (default 0.8.1) # OPENPENCIL_TARGET cargo target triple (e.g. x86_64-apple-darwin); # builds + bundles for that triple and reads the # bundle from target//release/bundle/osx @@ -49,7 +49,7 @@ set -euo pipefail WS_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -APP_VERSION="${OPENPENCIL_VERSION:-0.8.0}" +APP_VERSION="${OPENPENCIL_VERSION:-0.8.1}" TARGET_TRIPLE="${OPENPENCIL_TARGET:-}" # Locate cargo-bundle. Tries PATH first, then a workspace-local diff --git a/scripts/install-op.sh b/scripts/install-op.sh index db80c5c01..14928e5f6 100755 --- a/scripts/install-op.sh +++ b/scripts/install-op.sh @@ -7,7 +7,7 @@ # # Usage: # ./install-op.sh # install the latest stable release -# OP_VERSION=0.8.0 ./install-op.sh # pin a specific version +# OP_VERSION=0.8.1 ./install-op.sh # pin a specific version # OP_PRERELEASE=1 ./install-op.sh # allow the newest pre-release # INSTALL_DIR=$HOME/.local/bin ./install-op.sh # @@ -68,7 +68,7 @@ resolve_version() { tag="$(curl -fsSL "$api" | grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -n1 | sed 's/.*"\(v\{0,1\}[^"]*\)"$/\1/')" if [ -z "$tag" ]; then echo "error: could not resolve the latest release tag from GitHub" >&2 - echo " set OP_VERSION explicitly, e.g. OP_VERSION=0.8.0 $0" >&2 + echo " set OP_VERSION explicitly, e.g. OP_VERSION=0.8.1 $0" >&2 echo " or set OP_PRERELEASE=1 to allow pre-release tags" >&2 exit 1 fi diff --git a/scripts/package-windows.nsi b/scripts/package-windows.nsi index eaaafa4a3..bcd952881 100644 --- a/scripts/package-windows.nsi +++ b/scripts/package-windows.nsi @@ -25,10 +25,10 @@ ; ; Compile (relative paths resolve against this script's directory, so the ; workflow passes absolute /D defines): -; makensis "/DVERSION=0.8.0" "/DARCH=x64" ^ +; makensis "/DVERSION=0.8.1" "/DARCH=x64" ^ ; "/DBIN_DIR=D:\w\target\x86_64-pc-windows-msvc\release" ^ ; "/DICON_FILE=D:\w\crates\op-host-desktop\assets\icon.ico" ^ -; "/DOUT_FILE=D:\w\OpenPencil-0.8.0-x64-win-setup.exe" ^ +; "/DOUT_FILE=D:\w\OpenPencil-0.8.1-x64-win-setup.exe" ^ ; scripts\package-windows.nsi ; ; NOT compiled locally (no makensis on the macOS dev machine) — first real diff --git a/tools/bundle-macos.sh b/tools/bundle-macos.sh index 08fa8194c..b4f6a6904 100755 --- a/tools/bundle-macos.sh +++ b/tools/bundle-macos.sh @@ -75,7 +75,7 @@ cat > "$APP/Contents/Info.plist" <CFBundleIconFileicon CFBundlePackageTypeAPPL CFBundleInfoDictionaryVersion6.0 - CFBundleShortVersionString0.8.0 + CFBundleShortVersionString0.8.1 NSHighResolutionCapable