feat(web): make credential persistence deployment-aware
This commit is contained in:
parent
a02b124aae
commit
3a41f2ea7f
52
Cargo.lock
generated
52
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 रिलीज़ सक्रिय विकास में है (नीचे रोडमैप देखें)।
|
||||
|
||||
## प्रोजेक्ट संरचना
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 リリースは現在活発に開発中です(下記のロードマップを参照)。
|
||||
|
||||
## プロジェクト構成
|
||||
|
||||
|
|
|
|||
|
|
@ -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 릴리스는 활발히 개발 중입니다 (아래 로드맵 참고).
|
||||
|
||||
## 프로젝트 구조
|
||||
|
||||
|
|
|
|||
39
README.md
39
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 находится в активной разработке (см. [Дорожная карта](#дорожная-карта) ниже).
|
||||
|
||||
## Структура проекта
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ด้านล่าง)
|
||||
|
||||
## โครงสร้างโปรเจกต์
|
||||
|
||||
|
|
|
|||
|
|
@ -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ı
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 版本正在積極開發中(請見下方路線圖)。
|
||||
|
||||
## 專案結構
|
||||
|
||||
|
|
|
|||
|
|
@ -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 发布正在积极开发中(参见下方路线图)。
|
||||
|
||||
## 项目结构
|
||||
|
||||
|
|
|
|||
43
RELEASE_NOTES/v0.8.1.md
Normal file
43
RELEASE_NOTES/v0.8.1.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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<String>,
|
||||
pub image_gen_profiles: Vec<ImageGenProfile>,
|
||||
pub active_image_gen_profile_id: Option<String>,
|
||||
pub image_gen_provider_menu_open: Option<usize>,
|
||||
|
|
@ -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<Item = &'a str>,
|
||||
) -> u64 {
|
||||
let used: std::collections::HashSet<u64> = 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,
|
||||
|
|
|
|||
|
|
@ -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::*`.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<BuiltinAgentConfig>,
|
||||
}
|
||||
|
||||
/// Parse a `/api/ai/stream` JSON body into an [`AiStreamRequest`].
|
||||
|
|
@ -79,6 +74,10 @@ pub fn parse_ai_stream_body(body: &str) -> Option<AiStreamRequest> {
|
|||
.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<AiStreamRequest> {
|
|||
max_output_tokens,
|
||||
thinking,
|
||||
effort,
|
||||
transient_builtin,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -199,50 +199,77 @@ pub fn write_sse_error<W: Write>(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<Box<dyn ChatProvider>> {
|
||||
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<Option<Box<dyn ChatProvider>>, 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<dyn ChatProvider>))
|
||||
}
|
||||
|
||||
/// 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<Box<dyn ChatProvider>> {
|
||||
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<Box<dyn ChatProvider>> {
|
||||
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<Box<dyn ChatProvider>> {
|
||||
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<dyn ChatProvider>),
|
||||
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<dyn ChatProvider>),
|
||||
}
|
||||
}
|
||||
|
||||
#[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;
|
||||
|
|
|
|||
163
crates/op-host-services/src/ai_proxy_tests.rs
Normal file
163
crates/op-host-services/src/ai_proxy_tests.rs
Normal file
|
|
@ -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::<Vec<String>>(&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::<Vec<String>>(&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::<Vec<String>>(&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"
|
||||
);
|
||||
}
|
||||
|
|
@ -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, String> {
|
||||
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<ChatDelta> {
|
|||
});
|
||||
}
|
||||
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<ChatDelta> {
|
|||
"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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -278,24 +278,17 @@ pub struct HttpRequest {
|
|||
pub method: String,
|
||||
pub path: String,
|
||||
pub body: String,
|
||||
pub host: Option<String>,
|
||||
pub origin: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<S: std::io::Read>(stream: &mut S) -> Result<HttpRequest, String> {
|
||||
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<u8> = Vec::new();
|
||||
let mut byte = [0u8; 1];
|
||||
|
|
@ -347,17 +340,28 @@ pub fn read_http_request<S: std::io::Read>(stream: &mut S) -> Result<HttpRequest
|
|||
.flatten()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if path == "/api/settings/credentials"
|
||||
&& content_length > 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<S: std::io::Read>(stream: &mut S) -> Result<HttpRequest
|
|||
method,
|
||||
path,
|
||||
body: String::from_utf8_lossy(&body).into_owned(),
|
||||
host,
|
||||
origin,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -398,6 +404,7 @@ pub fn write_mcp_http_response<S: std::io::Write>(
|
|||
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(),
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -303,7 +303,10 @@ pub fn codex_models_from_app_server() -> Option<Vec<ModelEntry>> {
|
|||
// `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()?;
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
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<OpenverseOAuthPayload>,
|
||||
#[serde(default)]
|
||||
openverse_credential_owner: Option<String>,
|
||||
#[serde(default)]
|
||||
auto_update_enabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
experimental_features_enabled: Option<bool>,
|
||||
|
|
@ -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<Locale> {
|
|||
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<std::fs::File>,
|
||||
}
|
||||
|
||||
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<PendingSettingsFile, String> {
|
||||
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 {
|
||||
|
|
|
|||
220
crates/op-host-services/src/settings_io_checked.rs
Normal file
220
crates/op-host-services/src/settings_io_checked.rs
Normal file
|
|
@ -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<String, serde_json::Value>,
|
||||
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(())
|
||||
}
|
||||
440
crates/op-host-services/src/settings_io_checked_tests.rs
Normal file
440
crates/op-host-services/src/settings_io_checked_tests.rs
Normal file
|
|
@ -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::<SettingsPayload>(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::<Vec<_>>();
|
||||
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");
|
||||
}
|
||||
|
|
@ -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::<Vec<_>>();
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -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<PathBuf>,
|
||||
|
|
@ -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<PathBuf>,
|
||||
) -> 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<PathBuf>,
|
||||
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<F>(
|
||||
method: &str,
|
||||
path: &str,
|
||||
state: &mut WebCanvasState,
|
||||
settings_before: crate::settings_io::Fingerprint,
|
||||
credential_settings_before: Option<AgentSettings>,
|
||||
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<F>(
|
||||
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<String> {
|
||||
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<F>(
|
||||
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<op_editor_core::AgentProvider> {
|
||||
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<op_editor_core::AgentProvider> {
|
||||
use op_editor_core::AgentProvider;
|
||||
let normalized = raw
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric())
|
||||
.flat_map(|c| c.to_lowercase())
|
||||
.collect::<String>();
|
||||
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<serde_json::Value> = 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::Value> = 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<PathBuf>) -> Result<EditorState, String> {
|
||||
fn startup_editor_for_web_canvas_with_loader<Checked>(
|
||||
path: Option<PathBuf>,
|
||||
_policy: WebCredentialPersistence,
|
||||
checked_load: Checked,
|
||||
) -> Result<EditorState, String>
|
||||
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<PathBuf>,
|
||||
policy: WebCredentialPersistence,
|
||||
) -> Result<EditorState, String> {
|
||||
startup_editor_for_web_canvas_with_loader(path, policy, crate::settings_io::load_checked)
|
||||
}
|
||||
|
||||
pub fn startup_editor_for_web_canvas(path: Option<PathBuf>) -> Result<EditorState, String> {
|
||||
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<PathBuf>) -> Result<EditorStat
|
|||
/// the in-memory document). Blocks until a token-authed shutdown request.
|
||||
pub fn run_web_canvas(path: Option<PathBuf>, 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<PathBuf>, 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<PathBuf>, port: u16, host: &str) -> Result<()
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn enforce_credential_persistence_policy<F>(
|
||||
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<S: Read + Write>(
|
|||
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<S: Read + Write>(
|
|||
};
|
||||
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<S: Read + Write>(
|
|||
.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<S: Read + Write>(
|
|||
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<S: Read + Write>(
|
|||
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<reqwest::Url> {
|
||||
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::<std::net::IpAddr>()
|
||||
.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
|
||||
|
|
|
|||
|
|
@ -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::<Vec<String>>(&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]
|
||||
|
|
|
|||
|
|
@ -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<u32>,
|
||||
history: Vec<(ChatHistoryRole, String)>,
|
||||
attachments: Vec<ChatAttachment>,
|
||||
transient_builtin: Option<BuiltinAgentConfig>,
|
||||
}
|
||||
|
||||
pub fn parse_standard_turn_body(body: &str) -> Option<WebStandardTurnRequest> {
|
||||
|
|
@ -65,6 +67,10 @@ pub fn parse_standard_turn_body(body: &str) -> Option<WebStandardTurnRequest> {
|
|||
.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<WebStandardTurnRequest> {
|
|||
agent_team_size,
|
||||
history,
|
||||
attachments,
|
||||
transient_builtin,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -171,6 +178,7 @@ pub fn stream_standard_turn<W: Write>(
|
|||
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<EditorState, String> {
|
||||
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<String> {
|
||||
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<Mutex<Option<ChatRequest>>>,
|
||||
}
|
||||
|
||||
impl ChatProvider for CaptureProvider {
|
||||
fn provider_label(&self) -> &str {
|
||||
"capture"
|
||||
}
|
||||
|
||||
fn send(&self, request: ChatRequest) -> Box<dyn Iterator<Item = ChatDelta> + 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;
|
||||
|
|
|
|||
317
crates/op-host-services/src/web_chat_standard_tests.rs
Normal file
317
crates/op-host-services/src/web_chat_standard_tests.rs
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
use super::*;
|
||||
use op_ai::chat_provider::{EffortLevel, ThinkingMode};
|
||||
|
||||
struct CaptureProvider {
|
||||
seen: Arc<Mutex<Option<ChatRequest>>>,
|
||||
}
|
||||
|
||||
impl ChatProvider for CaptureProvider {
|
||||
fn provider_label(&self) -> &str {
|
||||
"capture"
|
||||
}
|
||||
|
||||
fn send(&self, request: ChatRequest) -> Box<dyn Iterator<Item = ChatDelta> + 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"
|
||||
);
|
||||
}
|
||||
63
crates/op-host-services/src/web_credential_policy.rs
Normal file
63
crates/op-host-services/src/web_credential_policy.rs
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
577
crates/op-host-services/src/web_credentials.rs
Normal file
577
crates/op-host-services/src/web_credentials.rs
Normal file
|
|
@ -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<BuiltinAgentPayload>,
|
||||
#[serde(default)]
|
||||
image_gen_profiles: Vec<ImageGenProfilePayload>,
|
||||
active_image_gen_profile_id: Option<String>,
|
||||
openverse_oauth: Option<OpenverseOAuthPayload>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct BuiltinAgentPayload {
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
preset: Option<String>,
|
||||
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<String>,
|
||||
}
|
||||
|
||||
#[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<BuiltinAgentConfig> {
|
||||
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<reqwest::Url, String> {
|
||||
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<reqwest::Url, String> {
|
||||
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::<IpAddr>().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<BuiltinAgentConfig, String> {
|
||||
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<ImageGenProfile, String> {
|
||||
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<Item = &'a str>, prefix: &str) -> u64 {
|
||||
ids.filter_map(|id| id.strip_prefix(prefix)?.parse::<u64>().ok())
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.saturating_add(1)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "web_credentials_tests.rs"]
|
||||
mod tests;
|
||||
355
crates/op-host-services/src/web_credentials_tests.rs
Normal file
355
crates/op-host-services/src/web_credentials_tests.rs
Normal file
|
|
@ -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<std::ffi::OsString>,
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<crate::web_settings::Fingerprint>,
|
||||
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);
|
||||
},
|
||||
)?;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
//!
|
||||
|
|
|
|||
|
|
@ -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<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// 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<C: RepaintContext + 'static>(inner: Rc<RefCell<C>>, 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<C: RepaintContext + 'static>(inner: Rc<RefCell<C>>, 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<C: RepaintContext + 'static>(inner: Rc<RefCell<C>>, base: S
|
|||
cancelled: false,
|
||||
model,
|
||||
framework,
|
||||
credential,
|
||||
},
|
||||
VecDeque::new(),
|
||||
)));
|
||||
|
|
@ -302,6 +303,7 @@ fn drive<C: RepaintContext + 'static>(inner: Rc<RefCell<C>>, 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<C: RepaintContext + 'static>(inner: Rc<RefCell<C>>, 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<C: RepaintContext + 'static>(
|
|||
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<C: RepaintContext + 'static>(
|
|||
/// 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![
|
||||
|
|
|
|||
|
|
@ -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<dyn FnOnce(Vec<(String, Vec<u8>)>)>;
|
||||
|
||||
/// 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<dyn FnOnce(Vec<(String, Vec<u8>)>)>) {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<dyn Fn()>) -> Result<(), JsValue> {
|
||||
|
|
@ -58,6 +60,31 @@ pub fn get(url: &str, on_response: Rc<dyn Fn(String)>) -> 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<dyn Fn(u16, String)>) -> 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::<dyn FnMut()>::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<Rc<dyn Fn(String)>>)
|
|||
}
|
||||
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<dyn Fn(u16, String)>) -> 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::<dyn FnMut()>::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()
|
||||
}
|
||||
|
|
|
|||
77
crates/op-host-web/src/web_ai_credentials.rs
Normal file
77
crates/op-host-web/src/web_ai_credentials.rs
Normal file
|
|
@ -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<serde_json::Value>) {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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::<dyn FnMut()>::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::<js_sys::Function>()
|
||||
.call0(&wasm_bindgen::JsValue::NULL);
|
||||
return Err(error);
|
||||
}
|
||||
Ok(AiStreamHandle { xhr })
|
||||
}
|
||||
|
||||
|
|
@ -240,6 +260,19 @@ fn nonempty_str(obj: &serde_json::Map<String, serde_json::Value>, 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!(
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ use crate::repaint_ctx::RepaintContext;
|
|||
use crate::web_ai_transport::{post_ai_stream_to, AiEvent, AiStreamHandle};
|
||||
|
||||
type EventQueue = Rc<RefCell<VecDeque<AiEvent>>>;
|
||||
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<PreparedTurn> {
|
||||
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<PreparedTurn> {
|
|||
.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<String> {
|
|||
.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<ModelEntry> = 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::<Vec<_>>();
|
||||
let local_model_ids = local_agents
|
||||
.iter()
|
||||
.map(|agent| agent.model.trim())
|
||||
.collect::<std::collections::HashSet<_>>();
|
||||
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::<Vec<_>>();
|
||||
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;
|
||||
|
|
|
|||
213
crates/op-host-web/src/web_chat_credential_tests.rs
Normal file
213
crates/op-host-web/src/web_chat_credential_tests.rs
Normal file
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
415
crates/op-host-web/src/web_credential_sync.rs
Normal file
415
crates/op-host-web/src/web_credential_sync.rs
Normal file
|
|
@ -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<bool>,
|
||||
pending: Option<String>,
|
||||
in_flight: Option<String>,
|
||||
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<bool>) -> 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<u16>) -> 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<CredentialSyncState> = 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<bool> {
|
||||
if !(200..300).contains(&status) {
|
||||
return None;
|
||||
}
|
||||
serde_json::from_str::<CredentialPolicyResponse>(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<dyn Fn(u16, String)> = 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<dyn Fn(u16, String)> = 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<u16>) {
|
||||
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::<dyn FnMut()>::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<u16>) {
|
||||
#[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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -293,10 +293,8 @@ fn build_auto_generate_body(state: &EditorState) -> Option<String> {
|
|||
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<String> {
|
|||
"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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
#[serde(default)]
|
||||
env: std::collections::BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
url: Option<String>,
|
||||
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<RecentFile>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct CredentialFingerprint {
|
||||
builtin_agents: Vec<BuiltinAgentConfig>,
|
||||
acp_agents: Vec<AcpAgentConfig>,
|
||||
image_gen_profiles: Vec<ImageGenProfile>,
|
||||
active_image_gen_profile_id: Option<String>,
|
||||
recent_files: Vec<RecentFile>,
|
||||
openverse_client_id: String,
|
||||
openverse_client_secret: String,
|
||||
write_pending: bool,
|
||||
write_disabled: bool,
|
||||
pending_credential_json: Option<String>,
|
||||
pending_settings_json: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
|
|
@ -100,26 +108,31 @@ struct SettingsPayload {
|
|||
mcp_cli_enabled: Option<[bool; 6]>,
|
||||
#[serde(default)]
|
||||
images_advanced_open: Option<bool>,
|
||||
#[serde(default)]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
openverse_oauth: Option<OpenverseOAuthPayload>,
|
||||
#[serde(default)]
|
||||
auto_update_enabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
experimental_features_enabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
connected: Option<[bool; 5]>,
|
||||
#[serde(default)]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
builtin_agents: Option<Vec<BuiltinAgentPayload>>,
|
||||
#[serde(default)]
|
||||
acp_agents: Option<Vec<AcpAgentPayload>>,
|
||||
#[serde(default)]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
image_gen_profiles: Option<Vec<ImageGenProfilePayload>>,
|
||||
#[serde(default)]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
active_image_gen_profile_id: Option<String>,
|
||||
#[serde(default)]
|
||||
recent_files: Option<Vec<RecentFilePayload>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct CredentialPayload {
|
||||
version: u32,
|
||||
builtin_agents: Vec<BuiltinAgentPayload>,
|
||||
image_gen_profiles: Vec<ImageGenProfilePayload>,
|
||||
active_image_gen_profile_id: Option<String>,
|
||||
openverse_oauth: Option<OpenverseOAuthPayload>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<BuiltinAge
|
|||
})
|
||||
}
|
||||
|
||||
fn acp_agent_to_payload(agent: &AcpAgentConfig) -> 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<AcpAgentConfig> {
|
||||
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::<u64>().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<Locale> {
|
|||
})
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn storage_get(key: &str) -> Option<String> {
|
||||
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<String> {
|
||||
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;
|
||||
|
|
|
|||
411
crates/op-host-web/src/web_settings_acp_scrub_tests.rs
Normal file
411
crates/op-host-web/src/web_settings_acp_scrub_tests.rs
Normal file
|
|
@ -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"));
|
||||
}
|
||||
102
crates/op-host-web/src/web_settings_legacy.rs
Normal file
102
crates/op-host-web/src/web_settings_legacy.rs
Normal file
|
|
@ -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<SettingsPayload>,
|
||||
pub(super) sanitized_raw: Option<String>,
|
||||
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::<serde_json::Value>(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,
|
||||
}
|
||||
}
|
||||
291
crates/op-host-web/src/web_settings_lossless_tests.rs
Normal file
291
crates/op-host-web/src/web_settings_lossless_tests.rs
Normal file
|
|
@ -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::<Vec<_>>();
|
||||
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);
|
||||
}
|
||||
405
crates/op-host-web/src/web_settings_storage.rs
Normal file
405
crates/op-host-web/src/web_settings_storage.rs
Normal file
|
|
@ -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<String>,
|
||||
pending_settings_json: Option<String>,
|
||||
}
|
||||
|
||||
impl CredentialLoad {
|
||||
pub(crate) fn initial_settings_fingerprint(&self, state: &EditorState) -> Option<Fingerprint> {
|
||||
(!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<F>(
|
||||
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<String>,
|
||||
sanitized_credential_json: Option<String>,
|
||||
}
|
||||
|
||||
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::<serde_json::Value>(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<String> {
|
||||
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<F>(
|
||||
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<String> {
|
||||
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<F>(
|
||||
state: &EditorState,
|
||||
before: &mut CredentialFingerprint,
|
||||
mut persist: F,
|
||||
) -> Option<String>
|
||||
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<F>(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
|
||||
}
|
||||
704
crates/op-host-web/src/web_settings_tests.rs
Normal file
704
crates/op-host-web/src/web_settings_tests.rs
Normal file
|
|
@ -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));
|
||||
}
|
||||
249
crates/op-host-web/src/web_settings_validation.rs
Normal file
249
crates/op-host-web/src/web_settings_validation.rs
Normal file
|
|
@ -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<SettingsPayload, String> {
|
||||
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<CredentialPayload, String> {
|
||||
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<String, serde_json::Value>,
|
||||
) -> 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<String, serde_json::Value>,
|
||||
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(())
|
||||
}
|
||||
56
crates/op-host-web/src/web_storage.rs
Normal file
56
crates/op-host-web/src/web_storage.rs
Normal file
|
|
@ -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<String> {
|
||||
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<String> {
|
||||
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<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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/<triple>/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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ cat > "$APP/Contents/Info.plist" <<PLIST
|
|||
<key>CFBundleIconFile</key><string>icon</string>
|
||||
<key>CFBundlePackageType</key><string>APPL</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
|
||||
<key>CFBundleShortVersionString</key><string>0.8.0</string>
|
||||
<key>CFBundleShortVersionString</key><string>0.8.1</string>
|
||||
<key>NSHighResolutionCapable</key><true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
Loading…
Reference in a new issue