feat(web): make credential persistence deployment-aware

This commit is contained in:
Kayshen-X 2026-07-14 23:36:37 +08:00
parent a02b124aae
commit 3a41f2ea7f
75 changed files with 7240 additions and 1425 deletions

52
Cargo.lock generated
View file

@ -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",

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 रिलीज़ सक्रिय विकास में है (नीचे रोडमैप देखें)।
## प्रोजेक्ट संरचना

View file

@ -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

View file

@ -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 リリースは現在活発に開発中です(下記のロードマップを参照)。
## プロジェクト構成

View file

@ -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 릴리스는 활발히 개발 중입니다 (아래 로드맵 참고).
## 프로젝트 구조

View file

@ -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

View file

@ -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

View file

@ -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 находится в активной разработке (см. [Дорожная карта](#дорожная-карта) ниже).
## Структура проекта

View file

@ -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 ด้านล่าง)
## โครงสร้างโปรเจกต์

View file

@ -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ı

View file

@ -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

View file

@ -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 profilesDocker 映像檔不再內建 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 版本正在積極開發中(請見下方路線圖)。
## 專案結構

View file

@ -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 profilesDocker 镜像不再内置 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
View 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.

View file

@ -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,

View file

@ -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::*`.

View file

@ -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();

View file

@ -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

View file

@ -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;

View file

@ -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();

View file

@ -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;

View 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"
);
}

View file

@ -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,
}
}

View file

@ -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!(

View file

@ -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;

View file

@ -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(),

View file

@ -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}");

View file

@ -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()?;

View file

@ -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 {

View 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(())
}

View 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");
}

View file

@ -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]

View file

@ -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

View file

@ -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]

View file

@ -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;

View 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"
);
}

View 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);
}
}
}

View 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;

View 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));
}
}

View file

@ -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);
},
)?;

View file

@ -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.
//!

View file

@ -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![

View file

@ -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) {

View file

@ -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;

View file

@ -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()
}

View 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"));
}
}

View file

@ -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!(

View file

@ -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;

View 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"
);
}

View 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)
);
}
}

View file

@ -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");
}
}

View file

@ -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;

View 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"));
}

View 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,
}
}

View 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);
}

View 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
}

View 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));
}

View 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(())
}

View 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) };
}

View file

@ -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();

View file

@ -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;
}

View file

@ -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)"));
}

View file

@ -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:*",
},

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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": {

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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>