diff --git a/.env.example b/.env.example
index 34b53bb..a5dfbc4 100644
--- a/.env.example
+++ b/.env.example
@@ -9,12 +9,12 @@ DEEPSEEK_API_KEY=
# DEEPSEEK_API_BASE_URL=https://api.deepseek.com
# Optional. Used only by `pnpm test:deepseek` local smoke tests.
-# DEEPSEEK_SMOKE_MODEL=deepseek-chat
+# DEEPSEEK_SMOKE_MODEL=deepseek-v4-flash
# Optional. Used only by `pnpm test:deepseek:briefing` local workflow smoke tests.
# The bundled evidence folder is SMOKE SAMPLE evidence for local verification only.
# Replace before operational use when pointing the workflow at real business evidence.
-# DEEPSEEK_BRIEFING_SMOKE_MODEL=deepseek-chat
+# DEEPSEEK_BRIEFING_SMOKE_MODEL=deepseek-v4-flash
# DEEPSEEK_BRIEFING_EVIDENCE_DIR=docs/templates/operations-briefing-smoke-evidence
# DEEPSEEK_BRIEFING_SMOKE_MAX_TOKENS=900
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6a6a553..c55534a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -44,6 +44,13 @@ jobs:
- name: Build desktop frontend
run: pnpm --filter @deepseek-agent-os/desktop build
+ - name: Run onboarding and settings contract tests
+ run: |
+ node scripts/settings-panel.test.mjs
+ node scripts/onboarding-readiness.test.mjs
+ node scripts/windows-installed-ui-smoke.mjs --self-test
+ node scripts/release-local-check.mjs --self-test
+
- name: Run Rust tests
env:
CARGO_TARGET_DIR: ${{ runner.temp }}\deepseek-agent-os-cargo-target
diff --git a/CODE_SIGNING_POLICY.md b/CODE_SIGNING_POLICY.md
index 373d0ce..32756f0 100644
--- a/CODE_SIGNING_POLICY.md
+++ b/CODE_SIGNING_POLICY.md
@@ -1,14 +1,20 @@
# Code signing policy
-Last updated: 2026-07-18
+Last updated: 2026-07-19
## Current status
-DS Agent `v1.0.2` is unsigned. Windows may therefore display an unknown-publisher
-warning for that historical release. The project is preparing an application
-for the SignPath Foundation open-source program, but no DS Agent binary may be
-represented as SignPath-signed until the application is approved and the
-published artifact independently verifies as Authenticode `Valid`.
+DS Agent `v1.1.0` is intentionally published unsigned. Both `ds-agent.exe` and
+the Windows x64 NSIS installer are expected to report Authenticode `NotSigned`.
+Windows may therefore display `Unknown publisher` or a Microsoft Defender
+SmartScreen warning. Users should download only over HTTPS from the official
+GitHub Release and verify the published SHA-256 before running the installer.
+
+The SignPath Foundation application is submitted and approval is pending. No
+DS Agent binary may be represented as SignPath-signed until the application is
+approved and a later-version artifact independently verifies as Authenticode
+`Valid`. The project will not replace the immutable `v1.1.0` tag or asset if
+signing becomes available later.
For releases accepted into that program: **Free code signing provided by
[SignPath.io](https://signpath.io/), certificate by
@@ -51,13 +57,17 @@ them; guessed or placeholder identifiers are forbidden.
## Release verification
-Before a signed installer is published, maintainers must verify both the
-application executable and installer with Windows Authenticode. Evidence must
-bind the exact source commit, file name, product version, byte size, SHA-256,
-signer subject and certificate chain, timestamp, and `Valid` signature status.
-The installer downloaded back from GitHub must match the reviewed release
-asset. An invalid, missing, expired, unexpectedly issued, or unbound signature
-stops publication.
+For the unsigned `v1.1.0` exception, maintainers verify and disclose the actual
+`NotSigned` status of both the application executable and installer. Evidence
+must bind the exact source commit, file name, product version, byte size, and
+SHA-256. The installer downloaded back from GitHub must match the reviewed
+Release asset exactly. An unexpected signer, signature identity, hash, version,
+source, or asset mismatch stops publication.
+
+For any later release explicitly described as signed, maintainers must also
+verify the expected signer subject, certificate chain, timestamp, and
+Authenticode `Valid` status independently for both files. An invalid, missing,
+expired, unexpectedly issued, or unbound signature stops that signed release.
## Project purpose and restrictions
diff --git a/PRIVACY.md b/PRIVACY.md
index dbc2286..32a5501 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -1,6 +1,6 @@
# Privacy Policy
-Last updated: 2026-07-18
+Last updated: 2026-07-19
This policy describes the current published DS Agent desktop application and
public project. DS Agent is local-first and does not operate a project cloud
@@ -14,10 +14,13 @@ User-selected workspaces hold approved evidence, exports, reports, work
packages, screenshots, and other artifacts. This information is not silently
synced to a DS Agent-operated server.
-The current stable `v1.0.2` reads a user-supplied DeepSeek API key from the
-desktop process environment. The project does not provide a shared key. API
-keys must not be written to events, logs, UI state, exports, screenshots, or
-work packages.
+The current stable `v1.1.0` accepts one user-supplied DeepSeek API key through
+the onboarding screen and stores it in a dedicated Windows DPAPI-protected
+local vault. A process-environment key remains an explicit compatibility
+fallback and is never silently copied into that vault. The project does not
+provide a shared key. The raw key exists in frontend memory only while it is
+being entered and submitted; it must not be retained in UI state, events, logs,
+exports, screenshots, work packages, or verification receipts.
Uninstalling the application may not delete a user-selected workspace or every
application-data file. Review and remove those local locations separately when
@@ -28,10 +31,12 @@ you no longer want to retain them.
DS Agent transfers information to networked systems only for work requested by
the user or person operating the application:
-- **DeepSeek.** Model-backed work sends the user's prompt and the bounded
- context selected for that request to the configured DeepSeek API. The
- user-supplied API key is used for authentication. DeepSeek processes that
- request under its [privacy policy](https://cdn.deepseek.com/policies/en-US/deepseek-privacy-policy.html).
+- **DeepSeek.** Explicit credential verification contacts DeepSeek's balance
+ and model-list endpoints. Model-backed work sends the user's prompt and the
+ bounded context selected for that request to the configured DeepSeek API.
+ The user-supplied API key is used for authentication. DeepSeek processes
+ those requests under its
+ [privacy policy](https://cdn.deepseek.com/policies/en-US/deepseek-privacy-policy.html).
- **Web search and visited sites.** Source-linked web search can send the search
query to DuckDuckGo, a user-configured route, or an optional user-started
local bridge. Opening or submitting a requested web page sends normal
@@ -53,7 +58,7 @@ the user or person operating the application:
The optional local desktop bridge accepts only loopback addresses and is
started and controlled by the user. DS Agent does not install or supervise that
service. Production Microsoft and Google account registration and live
-mail/calendar writes are disabled in `v1.0.2`; offline connector contracts do
+mail/calendar writes are disabled in `v1.1.0`; offline connector contracts do
not authorize a production account or external write.
## What can be included in a model request
@@ -72,10 +77,11 @@ approval of a local action does not grant unrelated network authority.
## User choices
-Users control whether to configure a DeepSeek key, request model-backed work,
-run web search, open a web destination, import a remote skill source, or start
-an optional local bridge. Remove the relevant credential or do not invoke the
-network-backed capability to prevent that route from being used.
+Users control whether to configure or remove a DeepSeek key, run its explicit
+verification, request model-backed work, run web search, open a web destination,
+import a remote skill source, or start an optional local bridge. Remove the
+relevant credential or do not invoke the network-backed capability to prevent
+that route from being used.
For a sensitive security or privacy issue, use
[GitHub Private Vulnerability Reporting](https://github.com/Lee-take/dsagent/security/advisories/new).
diff --git a/README.md b/README.md
index 55060f1..0d0a0a8 100644
--- a/README.md
+++ b/README.md
@@ -11,8 +11,8 @@
- v1.0.2 stable ·
- Download for Windows ·
+ v1.1.0 stable ·
+ Download for Windows ·
Apache-2.0
@@ -155,7 +155,7 @@ high-risk action. See the full [model boundary](docs/AGENT_MODEL_BOUNDARY.md).
reconciliation contracts validated with offline adversarial fake providers.
Production Microsoft/Google account registration and live external-write
-authority remain disabled in v1.0.2. The release does not sign in to real
+authority remain disabled in v1.1.0. The release does not sign in to real
accounts, send real email, or create, change, or cancel real calendar events.
## Why Rust
@@ -168,9 +168,11 @@ remain thin; the Kernel and persistent projections own business state.
## Quick start
-1. Download the [Windows x64 installer](https://github.com/Lee-take/dsagent/releases/download/v1.0.2/DS.Agent_1.0.2_x64-setup.exe).
-2. Make your own valid `DEEPSEEK_API_KEY` available to the DS Agent process.
-3. Choose one local workspace on first run.
+1. Download the [Windows x64 installer](https://github.com/Lee-take/dsagent/releases/download/v1.1.0/DS.Agent_1.1.0_x64-setup.exe).
+2. Enter your own valid DeepSeek API key in onboarding and run the explicit
+ balance/model verification. The key is stored locally with Windows DPAPI.
+3. Choose one local workspace and let the readiness doctor verify its managed
+ directories and writability.
4. Describe the result you want in chat. DS Agent requests additional
permissions or prerequisites only when the task needs them.
@@ -178,18 +180,21 @@ A user-supplied DeepSeek API key is a required prerequisite. DS Agent does not
bundle a shared key or bypass DeepSeek access requirements; use remains subject
to DeepSeek's terms and account policies.
-The v1.0.2 installer is currently unsigned, so Windows may display an
-unknown-publisher warning. Read the [installation guide](docs/INSTALLATION.md)
-before installing.
+The v1.1.0 application executable and installer are Authenticode `NotSigned`.
+Windows may display `Unknown publisher` or a Microsoft Defender SmartScreen
+warning. Download only over HTTPS from this repository, verify the SHA-256 in
+the GitHub Release, and read the [installation guide](docs/INSTALLATION.md)
+before running the installer.
## Code signing policy
-DS Agent `v1.0.2` remains unsigned. The project is preparing an open-source
-signing application; no release is represented as signed until its application
-executable and installer independently verify as Authenticode `Valid`. For
-releases accepted into the program: **Free code signing provided by
-SignPath.io, certificate by SignPath Foundation.** See the full
-[code signing policy](CODE_SIGNING_POLICY.md) and [privacy policy](PRIVACY.md).
+DS Agent `v1.1.0` is intentionally unsigned. The SignPath Foundation application
+is submitted and approval is pending; no release is represented as signed.
+If the project is accepted, signing starts with a later new version and does not
+replace this immutable tag or asset. For releases accepted into the program:
+**Free code signing provided by SignPath.io, certificate by SignPath
+Foundation.** See the full [code signing policy](CODE_SIGNING_POLICY.md) and
+[privacy policy](PRIVACY.md).
### Build from source
@@ -204,22 +209,22 @@ example `D:\build-target\ds-agent-v1-release`.
## Stable release
-- Release: [DS Agent v1.0.2](https://github.com/Lee-take/dsagent/releases/tag/v1.0.2)
-- Installer: `DS.Agent_1.0.2_x64-setup.exe`
-- Size: `12,714,353 bytes`
-- SHA-256: `21459D5A8CFF2606171CBD52B9D5508A40434101693BEFA81E8DC2D9EBF50E3D`
-- Fix: approvals now stay with their owning task, and one task with several
- permissions needs only one confirm-or-reject decision.
-- Validation: source secret scan, production frontend build, Node/UI checks,
- and 852 Rust tests with 845 passed, seven permission-gated live/GUI tests
- intentionally ignored, and zero failed.
+- Release: [DS Agent v1.1.0](https://github.com/Lee-take/dsagent/releases/tag/v1.1.0)
+- Installer: `DS.Agent_1.1.0_x64-setup.exe`
+- Integrity: verify the final byte size and SHA-256 published in the GitHub
+ Release before running the installer.
+- Onboarding: one user-supplied Key, Windows DPAPI storage, explicit DeepSeek
+ balance/V4 model verification, secret-free readiness, and workspace doctor.
+- Compatibility: existing workspace settings remain readable; environment-key
+ operators require explicit verification; conversations and connector vaults
+ are not rewritten.
## Documentation
- [Installation](docs/INSTALLATION.md)
- [DS Agent and DeepSeek boundary](docs/AGENT_MODEL_BOUNDARY.md)
- [v1 architecture](docs/architecture/DS_AGENT_V1_ARCHITECTURE_PLAN.md)
-- [v1.0.2 release notes](docs/RELEASE_NOTES_v1.0.2.md)
+- [v1.1.0 release notes](docs/RELEASE_NOTES_v1.1.0.md)
- [v1 completion audit](docs/DS_AGENT_V1_COMPLETION_AUDIT.md)
- [Security](SECURITY.md) · [Privacy](PRIVACY.md) ·
[Code signing policy](CODE_SIGNING_POLICY.md) ·
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 65bc533..1b5fa17 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -11,8 +11,8 @@
- v1.0.2 正式稳定版 ·
- 下载 Windows 安装包 ·
+ v1.1.0 正式稳定版 ·
+ 下载 Windows 安装包 ·
Apache-2.0
@@ -138,7 +138,7 @@ DeepSeek 可以提出动作,DS Agent 决定动作是否安全、是否允许
- 已通过离线对抗性 fake provider 验证的 Microsoft/Google 形态邮件、日历、同步、草稿、
外部变更和对账契约。
-v1.0.2 仍未开放生产 Microsoft/Google 账号注册和真实外部写入权限。当前正式版不会登录
+v1.1.0 仍未开放生产 Microsoft/Google 账号注册和真实外部写入权限。当前正式版不会登录
真实账号、发送真实邮件,也不会创建、修改或取消真实日历事件。
## 为什么使用 Rust
@@ -149,24 +149,28 @@ command 与 React UI 保持薄层,业务状态由 Kernel 和持久投影统一
## 快速开始
-1. 下载 [Windows x64 安装包](https://github.com/Lee-take/dsagent/releases/download/v1.0.2/DS.Agent_1.0.2_x64-setup.exe)。
-2. 让 DS Agent 进程能够读取你自己的有效 `DEEPSEEK_API_KEY`。
-3. 首次运行时选择一个本地工作目录。
+1. 下载 [Windows x64 安装包](https://github.com/Lee-take/dsagent/releases/download/v1.1.0/DS.Agent_1.1.0_x64-setup.exe)。
+2. 在首次设置中输入你自己的有效 DeepSeek API Key,并显式验证余额和模型;Key 使用
+ Windows DPAPI 保存在本机。
+3. 选择一个本地工作目录,由 readiness doctor 检查受管目录和可写状态。
4. 在聊天中描述你希望完成的结果;只有任务确实需要时,DS Agent 才会请求额外权限或
前置条件。
用户自行提供有效的 DeepSeek API Key 是必备前提。DS Agent 不内置共享 Key,也不会
绕过 DeepSeek 的访问条件;实际使用仍须遵守 DeepSeek 的服务条款和账号规则。
-v1.0.2 安装包目前尚未签名,Windows 可能显示“未知发布者”提示。安装前请阅读
+v1.1.0 应用程序和安装包的 Authenticode 状态均为 `NotSigned`。Windows 可能显示
+`Unknown publisher`(未知发布者)或 Microsoft Defender SmartScreen 警告。请只通过
+本仓库的 HTTPS 链接下载,运行前核对 GitHub Release 中的 SHA-256,并阅读
[安装指南](docs/INSTALLATION.md)。
## Code signing policy(代码签名政策)
-DS Agent `v1.0.2` 仍未签名。项目正在准备开源签名申请;只有应用程序和安装包都独立回读为
-Authenticode `Valid`,才会将对应 Release 声明为已签名。获准加入该计划的 Release 将遵循:
-**Free code signing provided by SignPath.io, certificate by SignPath Foundation.**
-完整说明见[代码签名政策](CODE_SIGNING_POLICY.md)和[隐私政策](PRIVACY.md)。
+DS Agent `v1.1.0` 是如实披露的未签名版本。SignPath Foundation 申请已经提交、仍在等待
+审批,本版本不会被描述为已签名。若以后获批,只从后续新版本开始签名,不替换本版本不可
+移动的 tag 或资产。获准加入该计划的 Release 将遵循:**Free code signing provided by
+SignPath.io, certificate by SignPath Foundation.** 完整说明见
+[代码签名政策](CODE_SIGNING_POLICY.md)和[隐私政策](PRIVACY.md)。
### 从源码运行
@@ -181,20 +185,20 @@ npx pnpm@9.15.9 --filter @deepseek-agent-os/desktop tauri:dev
## 正式稳定版
-- Release:[DS Agent v1.0.2](https://github.com/Lee-take/dsagent/releases/tag/v1.0.2)
-- 安装包:`DS.Agent_1.0.2_x64-setup.exe`
-- 大小:`12,714,353 bytes`
-- SHA-256:`21459D5A8CFF2606171CBD52B9D5508A40434101693BEFA81E8DC2D9EBF50E3D`
-- 修复:审批只跟随所属任务;同一任务需要多项权限时,只需一次确认或拒绝。
-- 验证:源文件密钥扫描、前端 production build、全部 Node/UI 检查以及 852 项 Rust
- 测试;845 项通过,7 项需要额外权限的 live/GUI 测试按设计忽略,0 项失败。
+- Release:[DS Agent v1.1.0](https://github.com/Lee-take/dsagent/releases/tag/v1.1.0)
+- 安装包:`DS.Agent_1.1.0_x64-setup.exe`
+- 完整性:运行安装包前,核对 GitHub Release 中发布的最终字节数和 SHA-256。
+- 首次设置:单一用户 Key、Windows DPAPI 本机存储、显式 DeepSeek 余额/V4 模型验证、
+ 无密 readiness 和 workspace doctor。
+- 兼容性:既有 workspace 设置继续可读;环境 Key 需要显式验证;不会重写历史会话或
+ connector vault。
## 文档
- [安装指南](docs/INSTALLATION.md)
- [DS Agent 与 DeepSeek 的工作边界](docs/AGENT_MODEL_BOUNDARY.md)
- [v1 架构计划](docs/architecture/DS_AGENT_V1_ARCHITECTURE_PLAN.md)
-- [v1.0.2 发布说明](docs/RELEASE_NOTES_v1.0.2.md)
+- [v1.1.0 发布说明](docs/RELEASE_NOTES_v1.1.0.md)
- [v1 完成审计](docs/DS_AGENT_V1_COMPLETION_AUDIT.md)
- [安全说明](SECURITY.md) · [隐私政策](PRIVACY.md) ·
[代码签名政策](CODE_SIGNING_POLICY.md) · [参与贡献](CONTRIBUTING.md) ·
diff --git a/SECURITY.md b/SECURITY.md
index 42a8a8d..4b8bf65 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -1,6 +1,6 @@
# Security Policy
-DS Agent is a local-first Windows desktop agent. DS Agent v1.0.2 is the current
+DS Agent is a local-first Windows desktop agent. DS Agent v1.1.0 is the current
published stable release and is not an official DeepSeek product. Security
reports are welcome, especially around local credentials, permission gates,
audit records, Computer Use boundaries, update integrity, code signing, and
@@ -10,6 +10,7 @@ package import/export behavior.
| Version | Supported |
| --- | --- |
+| 1.1.0 | Supported |
| 1.0.2 | Supported |
## Reporting A Vulnerability
@@ -29,12 +30,13 @@ Include:
## Security Boundaries
-- Current stable v1.0.2 reads a user-supplied DeepSeek API key from the desktop
- process environment. It must not be stored in events, UI state, logs, or
- exported work packages.
-- Unpublished Step 1 onboarding work proposes a dedicated Windows DPAPI vault
- and secret-free readiness receipt. It is not part of the current stable
- release and must not be described as published until its release gates pass.
+- Current stable v1.1.0 stores one user-supplied DeepSeek API key in a dedicated
+ Windows DPAPI vault. A process-environment key is an explicit compatibility
+ fallback and is not copied into the vault. Presence alone is never treated as
+ verified readiness; balance and required V4 model checks produce only a
+ secret-free local receipt. Raw keys, provider bodies, account details, and
+ absolute vault paths must not be stored in events, retained UI state, logs,
+ screenshots, or exported work packages.
- `pnpm test:secrets` scans tracked and unignored repository files for live
`sk-` style keys and non-empty `DEEPSEEK_API_KEY` assignments without printing
candidate values.
@@ -49,7 +51,9 @@ Include:
memory.
- Release identity must follow the [code signing policy](CODE_SIGNING_POLICY.md).
An unsigned or invalidly signed artifact must not be represented as a signed
- release. See also the [privacy policy](PRIVACY.md).
+ release. DS Agent v1.1.0 is explicitly disclosed as Authenticode `NotSigned`;
+ Windows may show `Unknown publisher` or a Microsoft Defender SmartScreen
+ warning. See also the [privacy policy](PRIVACY.md).
## Out Of Scope For Alpha
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 6a4f384..1b4d45d 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-agent-os/desktop",
"private": true,
- "version": "1.0.2",
+ "version": "1.1.0",
"author": "Lee take",
"license": "Apache-2.0",
"type": "module",
diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock
index e62f5da..e1f2880 100644
--- a/apps/desktop/src-tauri/Cargo.lock
+++ b/apps/desktop/src-tauri/Cargo.lock
@@ -794,7 +794,7 @@ dependencies = [
[[package]]
name = "deepseek-agent-os-desktop"
-version = "1.0.2"
+version = "1.1.0"
dependencies = [
"base64 0.22.1",
"chrono",
diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml
index 559b5c8..e42a443 100644
--- a/apps/desktop/src-tauri/Cargo.toml
+++ b/apps/desktop/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "deepseek-agent-os-desktop"
-version = "1.0.2"
+version = "1.1.0"
description = "Local-first DeepSeek AI work platform"
edition = "2021"
license = "Apache-2.0"
diff --git a/apps/desktop/src-tauri/src/app_paths.rs b/apps/desktop/src-tauri/src/app_paths.rs
new file mode 100644
index 0000000..08ac60b
--- /dev/null
+++ b/apps/desktop/src-tauri/src/app_paths.rs
@@ -0,0 +1,101 @@
+use std::path::{Path, PathBuf};
+
+use tauri::Manager;
+
+const ISOLATED_PROFILE_MODE_ENV: &str = "DS_AGENT_UI_SMOKE_PROFILE_MODE";
+const ISOLATED_PROFILE_MODE: &str = "isolated-clean";
+const ISOLATED_APP_DATA_DIR_ENV: &str = "DS_AGENT_UI_SMOKE_APP_DATA_DIR";
+const ISOLATED_PROFILE_PREFIX: &str = "ds-agent-ui-profile-";
+const ISOLATED_APP_DATA_DIR_NAME: &str = "appdata";
+
+pub fn resolve_app_data_dir(app: &tauri::AppHandle) -> Result {
+ if std::env::var(ISOLATED_PROFILE_MODE_ENV).ok().as_deref() != Some(ISOLATED_PROFILE_MODE) {
+ return app.path().app_data_dir().map_err(|error| error.to_string());
+ }
+
+ let requested = std::env::var(ISOLATED_APP_DATA_DIR_ENV)
+ .map(PathBuf::from)
+ .map_err(|_| "isolated app-data override is missing".to_string())?;
+ validate_isolated_app_data_dir(&requested, &std::env::temp_dir())
+}
+
+pub trait AppDataDirExt {
+ fn resolved_app_data_dir(&self) -> Result;
+}
+
+impl AppDataDirExt for tauri::AppHandle {
+ fn resolved_app_data_dir(&self) -> Result {
+ resolve_app_data_dir(self)
+ }
+}
+
+fn validate_isolated_app_data_dir(requested: &Path, temp_root: &Path) -> Result {
+ if !requested.is_absolute() {
+ return Err("isolated app-data override must be absolute".to_string());
+ }
+
+ let requested_metadata = std::fs::symlink_metadata(requested)
+ .map_err(|_| "isolated app-data override is unavailable".to_string())?;
+ if !requested_metadata.is_dir() || requested_metadata.file_type().is_symlink() {
+ return Err("isolated app-data override must be a real directory".to_string());
+ }
+
+ let profile_root = requested
+ .parent()
+ .ok_or_else(|| "isolated app-data override has no profile root".to_string())?;
+ let profile_metadata = std::fs::symlink_metadata(profile_root)
+ .map_err(|_| "isolated profile root is unavailable".to_string())?;
+ if !profile_metadata.is_dir() || profile_metadata.file_type().is_symlink() {
+ return Err("isolated profile root must be a real directory".to_string());
+ }
+
+ let canonical_app_data = std::fs::canonicalize(requested)
+ .map_err(|_| "isolated app-data override could not be verified".to_string())?;
+ let canonical_profile_root = std::fs::canonicalize(profile_root)
+ .map_err(|_| "isolated profile root could not be verified".to_string())?;
+ let canonical_temp_root = std::fs::canonicalize(temp_root)
+ .map_err(|_| "system temp root could not be verified".to_string())?;
+
+ if canonical_app_data.parent() != Some(canonical_profile_root.as_path())
+ || canonical_profile_root.parent() != Some(canonical_temp_root.as_path())
+ || canonical_app_data
+ .file_name()
+ .and_then(|name| name.to_str())
+ != Some(ISOLATED_APP_DATA_DIR_NAME)
+ || !canonical_profile_root
+ .file_name()
+ .and_then(|name| name.to_str())
+ .is_some_and(|name| name.starts_with(ISOLATED_PROFILE_PREFIX))
+ {
+ return Err("isolated app-data override escaped the verified temp profile".to_string());
+ }
+
+ Ok(canonical_app_data)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::validate_isolated_app_data_dir;
+
+ #[test]
+ fn isolated_app_data_accepts_only_the_verified_temp_profile_shape() {
+ let temp = tempfile::tempdir().expect("temp root");
+ let profile = temp.path().join("ds-agent-ui-profile-test");
+ let app_data = profile.join("appdata");
+ std::fs::create_dir_all(&app_data).expect("create isolated app data");
+
+ assert_eq!(
+ validate_isolated_app_data_dir(&app_data, temp.path()).expect("valid override"),
+ std::fs::canonicalize(app_data).expect("canonical app data")
+ );
+ }
+
+ #[test]
+ fn isolated_app_data_rejects_a_non_profile_directory() {
+ let temp = tempfile::tempdir().expect("temp root");
+ let app_data = temp.path().join("not-a-profile").join("appdata");
+ std::fs::create_dir_all(&app_data).expect("create app data");
+
+ assert!(validate_isolated_app_data_dir(&app_data, temp.path()).is_err());
+ }
+}
diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs
index f8f0727..89e9265 100644
--- a/apps/desktop/src-tauri/src/commands.rs
+++ b/apps/desktop/src-tauri/src/commands.rs
@@ -9,7 +9,9 @@ use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Manager, State};
use uuid::Uuid;
+use zeroize::Zeroizing;
+use crate::app_paths::AppDataDirExt;
use crate::connected_work_commands::{
dispatch_connected_work_agent_action, is_connected_work_agent_action,
};
@@ -71,12 +73,16 @@ use crate::kernel::connectors::runtime_registry::{
ConnectorMutationRegistry, ConnectorOAuthRegistry, ConnectorRuntimeRegistries,
};
use crate::kernel::deepseek::{
- build_deepseek_chat_completion_request, current_deepseek_credential_status,
- execute_deepseek_chat_completion, execute_deepseek_chat_completion_with_cache,
- execute_deepseek_user_balance, DeepSeekChatCacheState, DeepSeekChatCacheStatus,
- DeepSeekChatCompletionTransport, DeepSeekChatTelemetry, DeepSeekCredentialStatus,
- DeepSeekMemoryChatCompletionCache, DeepSeekOperationsBriefingSynthesizer,
- DeepSeekUserBalanceResponse, HttpDeepSeekChatCompletionTransport, DEEPSEEK_API_KEY_ENV,
+ build_deepseek_chat_completion_request, execute_deepseek_chat_completion,
+ execute_deepseek_chat_completion_with_cache, DeepSeekChatCacheState, DeepSeekChatCacheStatus,
+ DeepSeekChatCompletionTransport, DeepSeekChatTelemetry, DeepSeekMemoryChatCompletionCache,
+ DeepSeekOperationsBriefingSynthesizer, HttpDeepSeekChatCompletionTransport,
+};
+#[cfg(windows)]
+use crate::kernel::deepseek_credential::{
+ build_onboarding_readiness_projection, DeepSeekCredentialRuntime, DeepSeekReadinessProjection,
+ FileDeepSeekCredentialStateStore, OnboardingReadinessProjection,
+ WindowsDeepSeekCredentialStore,
};
use crate::kernel::deepseek_pricing::{
estimate_deepseek_chat_cost_micro_usd, load_deepseek_pricing_state,
@@ -93,8 +99,9 @@ use crate::kernel::expert_team::{
use crate::kernel::local_directory::{
load_local_directory_state, local_directory_readiness_from_state,
save_local_directory_settings as persist_local_directory_settings,
+ workspace_readiness_projection_from_setup_error, workspace_readiness_projection_from_state,
LocalDirectoryReadinessStatus, LocalDirectorySettings, LocalDirectoryState,
- LOCAL_MEMORY_DIR_NAME,
+ WorkspaceReadinessProjection, LOCAL_MEMORY_DIR_NAME,
};
use crate::kernel::models::FoundationState;
use crate::kernel::models::TaskRecord;
@@ -187,6 +194,8 @@ pub struct AppState {
connector_runtime: Arc>,
computer_control_unlock: Arc>,
deepseek_chat_cache: Arc,
+ #[cfg(windows)]
+ deepseek_credentials: Arc>,
}
impl AppState {
@@ -200,15 +209,31 @@ impl AppState {
event_store
.recover_workspace_mutation_checkpoints(Utc::now())
.map_err(event_store_error)?;
+ #[cfg(windows)]
+ let connector_vault_root = connector_vault_root.as_ref().to_path_buf();
+ #[cfg(windows)]
+ let deepseek_vault_root = connector_vault_root
+ .parent()
+ .unwrap_or(&connector_vault_root)
+ .join("deepseek-credential");
+ #[cfg(windows)]
+ let deepseek_credentials = Arc::new(DeepSeekCredentialRuntime::new(
+ WindowsDeepSeekCredentialStore::new(&deepseek_vault_root)
+ .map_err(|code| code.as_str().to_string())?,
+ FileDeepSeekCredentialStateStore::new(&deepseek_vault_root)
+ .map_err(|code| code.as_str().to_string())?,
+ ));
Ok(Self {
event_store: Arc::new(Mutex::new(event_store)),
connector_registries: Arc::new(ConnectorRuntimeRegistries::empty()),
#[cfg(windows)]
connector_runtime: Arc::new(ConnectorRuntime::new(
- WindowsConnectorCredentialStore::new(connector_vault_root)?,
+ WindowsConnectorCredentialStore::new(&connector_vault_root)?,
)),
computer_control_unlock: Arc::new(Mutex::new(ComputerControlUnlockState::generated())),
deepseek_chat_cache: Arc::new(DeepSeekMemoryChatCompletionCache::default()),
+ #[cfg(windows)]
+ deepseek_credentials,
})
}
@@ -4407,8 +4432,8 @@ fn dispatch_agent_app_update_action_with_store_mutex(
fn current_work_package_tool_readiness(
local_directories: LocalDirectoryReadinessStatus,
+ deepseek: DeepSeekReadinessProjection,
) -> WorkPackageToolReadiness {
- let deepseek = current_deepseek_credential_status();
let foundation_state = FoundationState::default();
let tool_strategy = model_driven_tool_strategy_for_current_platform(
foundation_state.large_model_provider,
@@ -4452,11 +4477,50 @@ fn link_existing_memory_records(
fn current_local_directory_readiness(
app: &AppHandle,
) -> Result {
- let app_data_dir = app.path().app_data_dir().map_err(event_store_error)?;
+ let app_data_dir = app.resolved_app_data_dir()?;
let directory_state = load_local_directory_state(app_data_dir).map_err(event_store_error)?;
Ok(local_directory_readiness_from_state(&directory_state))
}
+#[cfg(windows)]
+fn current_workspace_readiness(app: &AppHandle) -> WorkspaceReadinessProjection {
+ let app_data_dir = match app.resolved_app_data_dir() {
+ Ok(path) => path,
+ Err(_) => return WorkspaceReadinessProjection::settings_invalid(),
+ };
+ match load_local_directory_state(app_data_dir) {
+ Ok(state) => workspace_readiness_projection_from_state(&state),
+ Err(_) => WorkspaceReadinessProjection::settings_invalid(),
+ }
+}
+
+#[cfg(windows)]
+fn onboarding_projection(
+ app: &AppHandle,
+ _state: &AppState,
+ deepseek: DeepSeekReadinessProjection,
+) -> OnboardingReadinessProjection {
+ build_onboarding_readiness_projection(
+ deepseek,
+ current_workspace_readiness(app),
+ env!("CARGO_PKG_VERSION"),
+ )
+}
+
+#[cfg(windows)]
+fn current_onboarding_projection(
+ app: &AppHandle,
+ state: &AppState,
+) -> OnboardingReadinessProjection {
+ onboarding_projection(
+ app,
+ state,
+ state
+ .deepseek_credentials
+ .projection(|name| std::env::var(name).ok()),
+ )
+}
+
fn computer_tool_strategy_for_command(
large_model_provider: LargeModelProvider,
network_search_source_model: Option,
@@ -4530,19 +4594,6 @@ fn agent_file_write_client(
})
}
-fn operations_briefing_deepseek_api_key_for_provider(
- read_env: impl Fn(&str) -> Option,
- large_model_provider: LargeModelProvider,
-) -> Option {
- if large_model_provider != LargeModelProvider::DeepSeek {
- return None;
- }
-
- read_env(DEEPSEEK_API_KEY_ENV)
- .map(|value| value.trim().to_string())
- .filter(|value| !value.is_empty())
-}
-
fn deepseek_telemetry_with_pricing(
mut telemetry: Vec,
pricing_settings: Option<&DeepSeekPricingSettings>,
@@ -4628,48 +4679,6 @@ fn deepseek_cache_status_context_label(status: DeepSeekChatCacheStatus) -> &'sta
}
}
-fn agent_chat_api_key_from_env(read_env: impl Fn(&str) -> Option) -> Option {
- read_env(DEEPSEEK_API_KEY_ENV).and_then(|value| normalize_agent_chat_api_key(Some(value)))
-}
-
-fn normalize_agent_chat_api_key(value: Option) -> Option {
- value.and_then(|value| {
- let trimmed = value.trim();
- if trimmed.is_empty() {
- None
- } else {
- Some(trimmed.to_string())
- }
- })
-}
-
-pub fn agent_chat_api_key_from_sources(
- session_api_key: Option,
- read_env: impl Fn(&str) -> Option,
-) -> Option {
- normalize_agent_chat_api_key(session_api_key).or_else(|| agent_chat_api_key_from_env(read_env))
-}
-
-fn push_unique_agent_chat_api_key(candidates: &mut Vec, value: Option) {
- if let Some(api_key) = normalize_agent_chat_api_key(value) {
- if !candidates.iter().any(|candidate| candidate == &api_key) {
- candidates.push(api_key);
- }
- }
-}
-
-pub fn agent_chat_api_key_candidates_from_sources(
- primary_api_key: Option,
- fallback_api_key: Option,
- read_env: impl Fn(&str) -> Option,
-) -> Vec {
- let mut candidates = Vec::new();
- push_unique_agent_chat_api_key(&mut candidates, primary_api_key);
- push_unique_agent_chat_api_key(&mut candidates, fallback_api_key);
- push_unique_agent_chat_api_key(&mut candidates, read_env(DEEPSEEK_API_KEY_ENV));
- candidates
-}
-
fn agent_chat_response_from_telemetry(
content: String,
telemetry: &DeepSeekChatTelemetry,
@@ -8557,9 +8566,9 @@ fn agent_chat_with_transport_and_runtime_context_with_api_key_fallback(
request: AgentChatRequest,
runtime_context: AgentChatRuntimeContext,
pricing_settings: Option<&DeepSeekPricingSettings>,
-) -> Result<(AgentChatResponse, DeepSeekChatTelemetry, String), String> {
+) -> Result<(AgentChatResponse, DeepSeekChatTelemetry, usize), String> {
let mut errors = Vec::new();
- for api_key in api_keys {
+ for (index, api_key) in api_keys.iter().enumerate() {
match agent_chat_with_transport_and_runtime_context(
transport,
cache,
@@ -8568,7 +8577,7 @@ fn agent_chat_with_transport_and_runtime_context_with_api_key_fallback(
runtime_context.clone(),
pricing_settings,
) {
- Ok((response, telemetry)) => return Ok((response, telemetry, api_key.clone())),
+ Ok((response, telemetry)) => return Ok((response, telemetry, index)),
Err(error) => errors.push(error),
}
}
@@ -8576,7 +8585,7 @@ fn agent_chat_with_transport_and_runtime_context_with_api_key_fallback(
Err(errors
.last()
.cloned()
- .unwrap_or_else(|| "DeepSeek Chat is not configured. Provide a DeepSeek API key for this session or set DEEPSEEK_API_KEY in the local desktop process.".to_string()))
+ .unwrap_or_else(|| "key_missing".to_string()))
}
fn run_agent_chat_with_clients(
@@ -8666,7 +8675,7 @@ fn run_agent_chat_with_clients_and_api_keys_and_computer_use(
let mut runtime_context = runtime_context;
runtime_context.memory_context = memory_context;
runtime_context.skill_catalog = skill_catalog;
- let (response, first_telemetry, followup_api_key) =
+ let (response, first_telemetry, followup_api_key_index) =
agent_chat_with_transport_and_runtime_context_with_api_key_fallback(
transport,
cache,
@@ -8723,7 +8732,7 @@ fn run_agent_chat_with_clients_and_api_keys_and_computer_use(
let result = agent_chat_with_transport_and_runtime_context(
transport,
cache,
- &followup_api_key,
+ &api_keys[followup_api_key_index],
AgentChatRequest {
prompt: followup_prompt,
..request.clone()
@@ -13576,7 +13585,7 @@ fn agent_chat_runtime_context(
network_search_source_model: Option,
deepseek_chat_ready: bool,
) -> AgentChatRuntimeContext {
- let app_data_dir = app.path().app_data_dir().ok();
+ let app_data_dir = app.resolved_app_data_dir().ok();
let soul_profile = app_data_dir
.as_deref()
.and_then(|app_data_dir| load_agent_soul_profile_context(app_data_dir).ok())
@@ -13755,7 +13764,7 @@ pub fn execute_agent_tool(
}
}
let file_write_client = if request.tool_id.trim() == FILE_WRITE_TOOL_ID {
- let app_data_dir = app.path().app_data_dir().map_err(event_store_error)?;
+ let app_data_dir = app.resolved_app_data_dir()?;
let directory_state =
load_local_directory_state(&app_data_dir).map_err(event_store_error)?;
Some(agent_file_write_client(
@@ -13786,7 +13795,7 @@ pub fn execute_agent_tool(
request.tool_id.trim(),
COMPUTER_SCREENSHOT_TOOL_ID | COMPUTER_CONTROL_TOOL_ID
) {
- let app_data_dir = app.path().app_data_dir().map_err(event_store_error)?;
+ let app_data_dir = app.resolved_app_data_dir()?;
let directory_state =
load_local_directory_state(&app_data_dir).map_err(event_store_error)?;
let strategy = computer_tool_strategy_for_command(LargeModelProvider::DeepSeek, None);
@@ -13885,8 +13894,49 @@ pub fn execute_agent_tool(
}
#[tauri::command]
-pub fn get_deepseek_credential_status() -> DeepSeekCredentialStatus {
- current_deepseek_credential_status()
+pub fn get_onboarding_readiness(
+ app: AppHandle,
+ state: State<'_, AppState>,
+) -> OnboardingReadinessProjection {
+ current_onboarding_projection(&app, &state)
+}
+
+#[tauri::command]
+pub fn save_deepseek_api_key(
+ app: AppHandle,
+ api_key: String,
+ state: State<'_, AppState>,
+) -> Result {
+ let transport = HttpDeepSeekChatCompletionTransport::new()
+ .map_err(|_| "provider_protocol_error".to_string())?;
+ let deepseek = state
+ .deepseek_credentials
+ .save_and_verify(api_key, &transport, |name| std::env::var(name).ok());
+ Ok(onboarding_projection(&app, &state, deepseek))
+}
+
+#[tauri::command]
+pub fn verify_deepseek_api_key(
+ app: AppHandle,
+ state: State<'_, AppState>,
+) -> Result {
+ let transport = HttpDeepSeekChatCompletionTransport::new()
+ .map_err(|_| "provider_protocol_error".to_string())?;
+ let deepseek = state
+ .deepseek_credentials
+ .verify(&transport, |name| std::env::var(name).ok());
+ Ok(onboarding_projection(&app, &state, deepseek))
+}
+
+#[tauri::command]
+pub fn remove_deepseek_api_key(
+ app: AppHandle,
+ state: State<'_, AppState>,
+) -> OnboardingReadinessProjection {
+ let deepseek = state
+ .deepseek_credentials
+ .remove(|name| std::env::var(name).ok());
+ onboarding_projection(&app, &state, deepseek)
}
#[tauri::command]
@@ -13907,22 +13957,15 @@ pub fn run_agent_chat(
thinking_level: ThinkingLevel,
access_mode: AccessMode,
network_search_source_model: Option,
- api_key_override: Option,
- fallback_api_key_override: Option,
state: State<'_, AppState>,
) -> Result {
- let api_keys = agent_chat_api_key_candidates_from_sources(
- api_key_override,
- fallback_api_key_override,
- |name| std::env::var(name).ok(),
- );
- if api_keys.is_empty() {
- return Err("DeepSeek Chat is not configured. Provide a DeepSeek API key for this session or set DEEPSEEK_API_KEY in the local desktop process."
- .to_string());
- }
+ let api_key = state
+ .deepseek_credentials
+ .resolve_ready_key(|name| std::env::var(name).ok())
+ .map_err(|code| code.as_str().to_string())?;
+ let api_keys = Zeroizing::new(vec![api_key.expose().to_string()]);
let pricing_settings = app
- .path()
- .app_data_dir()
+ .resolved_app_data_dir()
.ok()
.and_then(|app_data_dir| load_deepseek_pricing_state(app_data_dir).ok())
.map(|pricing_state| pricing_state.settings);
@@ -13933,7 +13976,7 @@ pub fn run_agent_chat(
true,
);
let transport = HttpDeepSeekChatCompletionTransport::new()?;
- let app_data_dir = app.path().app_data_dir().map_err(event_store_error)?;
+ let app_data_dir = app.resolved_app_data_dir()?;
let directory_state = load_local_directory_state(&app_data_dir).map_err(event_store_error)?;
let desktop_dir = runtime_context.desktop_dir.clone();
let file_client = LocalFileContentClient::new(512 * 1024);
@@ -13947,11 +13990,11 @@ pub fn run_agent_chat(
&computer_strategy,
computer_screenshot_evidence_base_dir(&app_data_dir, &directory_state),
);
- run_agent_chat_with_clients_and_api_keys_and_computer_use(
+ let result = run_agent_chat_with_clients_and_api_keys_and_computer_use(
&state.event_store,
&transport,
&state.deepseek_chat_cache,
- &api_keys,
+ api_keys.as_slice(),
AgentChatRequest {
prompt,
model_route,
@@ -13965,7 +14008,8 @@ pub fn run_agent_chat(
&search_client,
&browser_client,
&computer_use_client,
- )
+ );
+ result
}
#[tauri::command]
@@ -13979,25 +14023,18 @@ pub async fn run_next_queued_agent_chat_worker(
thinking_level: ThinkingLevel,
access_mode: AccessMode,
network_search_source_model: Option,
- api_key_override: Option,
- fallback_api_key_override: Option,
state: State<'_, AppState>,
) -> Result, String> {
let event_store = Arc::clone(&state.event_store);
let cache = Arc::clone(&state.deepseek_chat_cache);
+ let credentials = Arc::clone(&state.deepseek_credentials);
tauri::async_runtime::spawn_blocking(move || {
- let api_keys = agent_chat_api_key_candidates_from_sources(
- api_key_override,
- fallback_api_key_override,
- |name| std::env::var(name).ok(),
- );
- if api_keys.is_empty() {
- return Err("DeepSeek Chat is not configured. Provide a DeepSeek API key for this session or set DEEPSEEK_API_KEY in the local desktop process."
- .to_string());
- }
+ let api_key = credentials
+ .resolve_ready_key(|name| std::env::var(name).ok())
+ .map_err(|code| code.as_str().to_string())?;
+ let api_keys = Zeroizing::new(vec![api_key.expose().to_string()]);
let pricing_settings = app
- .path()
- .app_data_dir()
+ .resolved_app_data_dir()
.ok()
.and_then(|app_data_dir| load_deepseek_pricing_state(app_data_dir).ok())
.map(|pricing_state| pricing_state.settings);
@@ -14008,9 +14045,10 @@ pub async fn run_next_queued_agent_chat_worker(
true,
);
let transport = HttpDeepSeekChatCompletionTransport::new()?;
- let app_data_dir = app.path().app_data_dir().map_err(event_store_error)?;
+ let app_data_dir = app.resolved_app_data_dir()?;
runtime_context.expert_staging_root = Some(app_data_dir.join("expert-team-staging"));
- let directory_state = load_local_directory_state(&app_data_dir).map_err(event_store_error)?;
+ let directory_state =
+ load_local_directory_state(&app_data_dir).map_err(event_store_error)?;
let desktop_dir = runtime_context.desktop_dir.clone();
let file_client = LocalFileContentClient::new(512 * 1024);
let file_write_client = agent_file_write_client(&directory_state, desktop_dir)?;
@@ -14024,11 +14062,11 @@ pub async fn run_next_queued_agent_chat_worker(
computer_screenshot_evidence_base_dir(&app_data_dir, &directory_state),
);
- run_queued_agent_chat_with_clients_and_api_keys_and_computer_use(
+ let result = run_queued_agent_chat_with_clients_and_api_keys_and_computer_use(
&event_store,
&transport,
&cache,
- &api_keys,
+ api_keys.as_slice(),
run_id,
execution_prompt,
worker_id,
@@ -14042,41 +14080,13 @@ pub async fn run_next_queued_agent_chat_worker(
&search_client,
&browser_client,
&computer_use_client,
- )
+ );
+ result
})
.await
.map_err(|error| format!("agent worker task failed: {error}"))?
}
-#[tauri::command]
-pub fn get_deepseek_user_balance(
- api_key_override: Option,
- fallback_api_key_override: Option,
-) -> Result {
- let api_keys = agent_chat_api_key_candidates_from_sources(
- api_key_override,
- fallback_api_key_override,
- |name| std::env::var(name).ok(),
- );
- if api_keys.is_empty() {
- return Err("DeepSeek API key is required to query user balance.".to_string());
- }
-
- let transport = HttpDeepSeekChatCompletionTransport::new()?;
- let mut errors = Vec::new();
- for api_key in api_keys {
- match execute_deepseek_user_balance(&transport, &api_key) {
- Ok(balance) => return Ok(balance),
- Err(error) => errors.push(error),
- }
- }
-
- Err(errors
- .last()
- .cloned()
- .unwrap_or_else(|| "DeepSeek balance query failed.".to_string()))
-}
-
#[tauri::command]
pub fn resume_agent_chat_action(
app: AppHandle,
@@ -14086,7 +14096,7 @@ pub fn resume_agent_chat_action(
action: AgentChatActionProposal,
state: State<'_, AppState>,
) -> Result {
- let app_data_dir = app.path().app_data_dir().map_err(event_store_error)?;
+ let app_data_dir = app.resolved_app_data_dir()?;
let directory_state = load_local_directory_state(&app_data_dir).map_err(event_store_error)?;
let desktop_dir = app.path().desktop_dir().ok();
let normalized_action = normalize_agent_action_proposal(action, access_mode);
@@ -14248,7 +14258,7 @@ pub fn clear_deepseek_chat_cache(state: State<'_, AppState>) -> usize {
#[tauri::command]
pub fn get_deepseek_pricing_state(app: AppHandle) -> Result {
- let app_data_dir = app.path().app_data_dir().map_err(event_store_error)?;
+ let app_data_dir = app.resolved_app_data_dir()?;
load_deepseek_pricing_state(app_data_dir).map_err(event_store_error)
}
@@ -14261,7 +14271,7 @@ pub fn save_deepseek_pricing_settings(
pro_prompt_usd_per_million_tokens: String,
pro_completion_usd_per_million_tokens: String,
) -> Result {
- let app_data_dir = app.path().app_data_dir().map_err(event_store_error)?;
+ let app_data_dir = app.resolved_app_data_dir()?;
persist_deepseek_pricing_settings(
app_data_dir,
DeepSeekPricingSettings {
@@ -14276,8 +14286,10 @@ pub fn save_deepseek_pricing_settings(
}
#[tauri::command]
-pub fn get_network_search_route_status() -> NetworkSearchRouteStatus {
- let deepseek_status = current_deepseek_credential_status();
+pub fn get_network_search_route_status(state: State<'_, AppState>) -> NetworkSearchRouteStatus {
+ let deepseek_status = state
+ .deepseek_credentials
+ .projection(|name| std::env::var(name).ok());
let foundation_state = FoundationState::default();
let tool_strategy = model_driven_tool_strategy_for_current_platform(
foundation_state.large_model_provider,
@@ -14590,7 +14602,7 @@ pub fn run_durable_computer_use_step(
.map_err(event_store_error)?;
}
- let app_data_dir = app.path().app_data_dir().map_err(event_store_error)?;
+ let app_data_dir = app.resolved_app_data_dir()?;
let directory_state = load_local_directory_state(&app_data_dir).map_err(event_store_error)?;
let screenshot_client = LocalComputerScreenshotClient::new(
computer_screenshot_evidence_base_dir(&app_data_dir, &directory_state),
@@ -14725,8 +14737,11 @@ pub fn list_durable_computer_use_steps(
pub fn get_network_search_route_status_for_model(
large_model_provider: LargeModelProvider,
network_search_source_model: Option,
+ state: State<'_, AppState>,
) -> NetworkSearchRouteStatus {
- let deepseek_status = current_deepseek_credential_status();
+ let deepseek_status = state
+ .deepseek_credentials
+ .projection(|name| std::env::var(name).ok());
let tool_strategy = model_driven_tool_strategy_for_current_platform(
large_model_provider,
network_search_source_model,
@@ -14758,14 +14773,13 @@ pub fn get_model_driven_tool_strategy(
}
#[tauri::command]
-pub fn get_local_directory_state(app: AppHandle) -> Result {
- let app_data_dir = app.path().app_data_dir().map_err(event_store_error)?;
- load_local_directory_state(app_data_dir).map_err(event_store_error)
+pub fn get_local_directory_state(app: AppHandle) -> WorkspaceReadinessProjection {
+ current_workspace_readiness(&app)
}
#[tauri::command]
pub fn get_agent_soul_profile(app: AppHandle) -> Result {
- let app_data_dir = app.path().app_data_dir().map_err(event_store_error)?;
+ let app_data_dir = app.resolved_app_data_dir()?;
agent_soul_profile_state_from_app_data_dir(&app_data_dir)
}
@@ -14774,7 +14788,7 @@ pub fn save_agent_soul_profile(
app: AppHandle,
content: String,
) -> Result {
- let app_data_dir = app.path().app_data_dir().map_err(event_store_error)?;
+ let app_data_dir = app.resolved_app_data_dir()?;
save_agent_soul_profile_content(&app_data_dir, &content)
}
@@ -14783,12 +14797,25 @@ pub fn save_local_directory_settings(
app: AppHandle,
workspace_dir: String,
workspace_name: String,
-) -> Result {
- let app_data_dir = app.path().app_data_dir().map_err(event_store_error)?;
- let settings =
- LocalDirectorySettings::from_workspace_dir_and_name(workspace_dir, workspace_name)
- .map_err(event_store_error)?;
- persist_local_directory_settings(app_data_dir, settings).map_err(event_store_error)
+ state: State<'_, AppState>,
+) -> Result {
+ let app_data_dir = app.resolved_app_data_dir()?;
+ let workspace =
+ match LocalDirectorySettings::from_workspace_dir_and_name(workspace_dir, workspace_name) {
+ Ok(settings) => match persist_local_directory_settings(app_data_dir, settings) {
+ Ok(directory_state) => workspace_readiness_projection_from_state(&directory_state),
+ Err(error) => workspace_readiness_projection_from_setup_error(&error),
+ },
+ Err(error) => workspace_readiness_projection_from_setup_error(&error),
+ };
+ let deepseek = state
+ .deepseek_credentials
+ .projection(|name| std::env::var(name).ok());
+ Ok(build_onboarding_readiness_projection(
+ deepseek,
+ workspace,
+ env!("CARGO_PKG_VERSION"),
+ ))
}
#[tauri::command]
@@ -15049,7 +15076,7 @@ pub fn queue_parent_agent_synthesis(
.expert_contract
.as_ref()
.ok_or_else(|| "Expert production contract is missing.".to_string())?;
- let app_data_dir = app.path().app_data_dir().map_err(event_store_error)?;
+ let app_data_dir = app.resolved_app_data_dir()?;
let staging_root = app_data_dir
.join("expert-team-staging")
.join(parent_run_id.to_string())
@@ -15369,19 +15396,18 @@ pub fn list_memory_maintenance_reviews(
#[tauri::command]
pub fn run_memory_background_maintenance(
- api_key_override: Option,
- fallback_api_key_override: Option,
+ app: AppHandle,
state: State<'_, AppState>,
) -> Result {
- let api_keys = agent_chat_api_key_candidates_from_sources(
- api_key_override,
- fallback_api_key_override,
- |name| std::env::var(name).ok(),
- );
- let store = state.event_store.lock().map_err(|_| lock_error())?;
- if let Some(api_key) = api_keys.first() {
+ let app_data_dir = app.resolved_app_data_dir()?;
+ let store =
+ EventStore::open(app_data_dir.join("kernel-events.sqlite3")).map_err(event_store_error)?;
+ if let Ok(api_key) = state
+ .deepseek_credentials
+ .resolve_ready_key(|name| std::env::var(name).ok())
+ {
let transport = HttpDeepSeekChatCompletionTransport::new()?;
- run_memory_background_maintenance_with_model_in_store(&store, &transport, api_key)
+ run_memory_background_maintenance_with_model_in_store(&store, &transport, api_key.expose())
} else {
run_memory_background_maintenance_in_store(&store)
}
@@ -16561,7 +16587,7 @@ pub fn write_file_boundary(
content: String,
state: State<'_, AppState>,
) -> Result {
- let app_data_dir = app.path().app_data_dir().map_err(event_store_error)?;
+ let app_data_dir = app.resolved_app_data_dir()?;
let directory_state = load_local_directory_state(&app_data_dir).map_err(event_store_error)?;
let client = agent_file_write_client(&directory_state, app.path().desktop_dir().ok())?;
if let AgentFileWriteClient::Unavailable(reason) = &client {
@@ -16716,7 +16742,7 @@ pub fn capture_computer_screenshot(
) -> Result {
let strategy =
computer_tool_strategy_for_command(large_model_provider, network_search_source_model);
- let app_data_dir = app.path().app_data_dir().map_err(event_store_error)?;
+ let app_data_dir = app.resolved_app_data_dir()?;
let directory_state = load_local_directory_state(&app_data_dir).map_err(event_store_error)?;
let client = agent_computer_use_client(
&strategy,
@@ -17010,7 +17036,12 @@ pub fn write_drive_boundary(
task_records,
memory_candidates,
operations_briefing_runs,
- current_work_package_tool_readiness(current_local_directory_readiness(&app)?),
+ current_work_package_tool_readiness(
+ current_local_directory_readiness(&app)?,
+ state
+ .deepseek_credentials
+ .projection(|name| std::env::var(name).ok()),
+ ),
);
serde_json::to_string_pretty(&package).map_err(event_store_error)?
};
@@ -17068,15 +17099,20 @@ pub fn run_operations_briefing(
approval_granted,
};
let mut deepseek_telemetry = Vec::new();
- let mut outcome = if let Some(api_key) = operations_briefing_deepseek_api_key_for_provider(
- |name| std::env::var(name).ok(),
- large_model_provider,
- ) {
+ let resolved_api_key = (large_model_provider == LargeModelProvider::DeepSeek)
+ .then(|| {
+ state
+ .deepseek_credentials
+ .resolve_ready_key(|name| std::env::var(name).ok())
+ .ok()
+ })
+ .flatten();
+ let mut outcome = if let Some(api_key) = resolved_api_key.as_ref() {
let transport = HttpDeepSeekChatCompletionTransport::new()?;
let synthesizer = DeepSeekOperationsBriefingSynthesizer::new_with_cache(
&transport,
state.deepseek_chat_cache.as_ref(),
- api_key,
+ api_key.expose().to_string(),
model_route,
thinking_level,
);
@@ -17088,8 +17124,7 @@ pub fn run_operations_briefing(
build_operations_briefing_run(request, &client)?
};
let pricing_settings = app
- .path()
- .app_data_dir()
+ .resolved_app_data_dir()
.ok()
.and_then(|app_data_dir| load_deepseek_pricing_state(app_data_dir).ok())
.map(|pricing_state| pricing_state.settings);
@@ -17151,7 +17186,7 @@ pub fn export_operations_briefing_report(
.find(|run| run.id == run_id)
.ok_or_else(|| "operations briefing run was not found".to_string())?
};
- let app_data_dir = app.path().app_data_dir().map_err(event_store_error)?;
+ let app_data_dir = app.resolved_app_data_dir()?;
let directory_state = load_local_directory_state(&app_data_dir).map_err(event_store_error)?;
let export_dir = operations_briefing_report_export_dir(&app_data_dir, &directory_state);
let report_markdown = render_operations_briefing_report(&run);
@@ -17212,7 +17247,7 @@ pub fn export_operations_briefing_html_report(
.find(|run| run.id == run_id)
.ok_or_else(|| "operations briefing run was not found".to_string())?
};
- let app_data_dir = app.path().app_data_dir().map_err(event_store_error)?;
+ let app_data_dir = app.resolved_app_data_dir()?;
let directory_state = load_local_directory_state(&app_data_dir).map_err(event_store_error)?;
let export_dir = operations_briefing_report_export_dir(&app_data_dir, &directory_state);
let report_html = render_operations_briefing_html_report(&run);
@@ -17273,7 +17308,7 @@ pub fn export_operations_briefing_pdf_report(
.find(|run| run.id == run_id)
.ok_or_else(|| "operations briefing run was not found".to_string())?
};
- let app_data_dir = app.path().app_data_dir().map_err(event_store_error)?;
+ let app_data_dir = app.resolved_app_data_dir()?;
let directory_state = load_local_directory_state(&app_data_dir).map_err(event_store_error)?;
let export_dir = operations_briefing_report_export_dir(&app_data_dir, &directory_state);
let report_pdf = render_operations_briefing_pdf_report(&run);
@@ -17350,7 +17385,7 @@ pub fn seed_operations_briefing_evidence_templates(
.has_user_approved_capability(CapabilityKind::FileWrite)
.map_err(event_store_error)?
};
- let app_data_dir = app.path().app_data_dir().map_err(event_store_error)?;
+ let app_data_dir = app.resolved_app_data_dir()?;
let directory_state = load_local_directory_state(&app_data_dir).map_err(event_store_error)?;
let seed_dir = operations_briefing_template_seed_dir(&app_data_dir, &directory_state);
let outcome = build_operations_briefing_template_seed(
@@ -17433,7 +17468,12 @@ pub fn export_work_package(
task_records,
memory_candidates,
operations_briefing_runs,
- current_work_package_tool_readiness(current_local_directory_readiness(&app)?),
+ current_work_package_tool_readiness(
+ current_local_directory_readiness(&app)?,
+ state
+ .deepseek_credentials
+ .projection(|name| std::env::var(name).ok()),
+ ),
))
}
@@ -17490,7 +17530,6 @@ mod tests {
NetworkSearchAgentToolExecutor, TerminalReadAgentToolExecutor, WorkspaceCheckpointStore,
};
use crate::commands::{
- agent_chat_api_key_candidates_from_sources, agent_chat_api_key_from_sources,
agent_chat_with_dispatch_and_tool_followup, agent_chat_with_transport,
apply_memory_candidate_update_if_current, block_subagent_mutating_actions,
computer_screenshot_evidence_base_dir, computer_tool_strategy_for_command,
@@ -17510,9 +17549,9 @@ mod tests {
dispatch_agent_skill_activate_tool_with_executor, dispatch_agent_skill_create_action,
dispatch_agent_skill_uninstall_action, ensure_system_skill_builder,
link_existing_memory_records, list_memory_maintenance_reviews_from_store,
- normalize_agent_action_proposal, operations_briefing_deepseek_api_key_for_provider,
- operations_briefing_model_route_context, operations_briefing_report_export_dir,
- operations_briefing_template_seed_dir, operations_briefing_token_cache_context,
+ normalize_agent_action_proposal, operations_briefing_model_route_context,
+ operations_briefing_report_export_dir, operations_briefing_template_seed_dir,
+ operations_briefing_token_cache_context,
propose_memory_update_candidate_from_feedback_in_store,
record_agent_action_permission_requests, record_memory_maintenance_review_action_in_store,
register_completed_office_artifact, resume_agent_chat_action_with_clients,
@@ -17592,6 +17631,55 @@ mod tests {
use std::time::Duration as StdDuration;
use uuid::Uuid;
+ fn command_source_section(source: &'static str, signature: &str) -> &'static str {
+ let start = source
+ .find(signature)
+ .expect("command signature is present");
+ let remainder = &source[start + signature.len()..];
+ let end = remainder
+ .find("\n#[tauri::command]")
+ .unwrap_or(remainder.len());
+ &source[start..start + signature.len() + end]
+ }
+
+ #[test]
+ fn deepseek_command_consumers_use_the_kernel_credential_runtime() {
+ const COMMANDS: &str = include_str!("commands.rs");
+ for signature in [
+ "pub fn run_agent_chat(",
+ "pub async fn run_next_queued_agent_chat_worker(",
+ "pub fn run_memory_background_maintenance(",
+ "pub fn run_operations_briefing(",
+ ] {
+ let section = command_source_section(COMMANDS, signature);
+ assert!(section.contains("deepseek_credentials"), "{signature}");
+ assert!(section.contains("resolve_ready_key"), "{signature}");
+ assert!(
+ !section.contains("std::env::var(DEEPSEEK_API_KEY_ENV)"),
+ "{signature}"
+ );
+ }
+
+ let save = command_source_section(COMMANDS, "pub fn save_deepseek_api_key(");
+ let verify = command_source_section(COMMANDS, "pub fn verify_deepseek_api_key(");
+ assert!(save.contains("deepseek_credentials"));
+ assert!(save.contains("save_and_verify"));
+ assert!(verify.contains("deepseek_credentials"));
+ assert!(verify.contains(".verify("));
+
+ const CREDENTIAL_RUNTIME: &str = include_str!("kernel/deepseek_credential.rs");
+ let balance = CREDENTIAL_RUNTIME
+ .find("verifier.fetch_user_balance")
+ .expect("balance verification is present");
+ let models = CREDENTIAL_RUNTIME
+ .find("verifier.fetch_models")
+ .expect("model verification is present");
+ assert!(
+ balance < models,
+ "balance verification must precede model verification"
+ );
+ }
+
#[test]
fn agent_chat_prompt_keeps_internal_receipts_out_of_ordinary_replies() {
let prompt = super::AGENT_CHAT_SYSTEM_PROMPT;
@@ -29567,48 +29655,6 @@ schema_version: 1
assert!(transport.recorded_requests().is_empty());
}
- #[test]
- fn agent_chat_api_key_prefers_session_override_without_persisting_secret() {
- let api_key = agent_chat_api_key_from_sources(Some(" session-key ".to_string()), |_| {
- Some("env-key".to_string())
- })
- .expect("session key should be accepted");
-
- assert_eq!(api_key, "session-key");
- }
-
- #[test]
- fn agent_chat_api_key_falls_back_to_environment_when_override_blank() {
- let api_key = agent_chat_api_key_from_sources(Some(" ".to_string()), |_| {
- Some(" env-key ".to_string())
- })
- .expect("env key should be accepted");
-
- assert_eq!(api_key, "env-key");
- }
-
- #[test]
- fn agent_chat_api_key_candidates_keep_primary_fallback_then_environment() {
- let api_keys = agent_chat_api_key_candidates_from_sources(
- Some(" primary-key ".to_string()),
- Some(" fallback-key ".to_string()),
- |name| (name == DEEPSEEK_API_KEY_ENV).then_some(" env-key ".to_string()),
- );
-
- assert_eq!(api_keys, vec!["primary-key", "fallback-key", "env-key"]);
- }
-
- #[test]
- fn agent_chat_api_key_candidates_drop_blanks_and_duplicates() {
- let api_keys = agent_chat_api_key_candidates_from_sources(
- Some(" same-key ".to_string()),
- Some("same-key".to_string()),
- |name| (name == DEEPSEEK_API_KEY_ENV).then_some(" ".to_string()),
- );
-
- assert_eq!(api_keys, vec!["same-key"]);
- }
-
#[test]
fn computer_screenshot_evidence_base_prefers_user_evidence_dir() {
let app_data_dir = std::path::PathBuf::from("fixtures/app-data");
@@ -29960,31 +30006,6 @@ schema_version: 1
}
}
- #[test]
- fn operations_briefing_uses_deepseek_key_only_for_deepseek_provider() {
- let key = operations_briefing_deepseek_api_key_for_provider(
- |name| (name == "DEEPSEEK_API_KEY").then_some("test-secret-token".to_string()),
- LargeModelProvider::DeepSeek,
- );
- let chatgpt_key = operations_briefing_deepseek_api_key_for_provider(
- |name| (name == "DEEPSEEK_API_KEY").then_some("test-secret-token".to_string()),
- LargeModelProvider::ChatGpt,
- );
-
- assert_eq!(key.as_deref(), Some("test-secret-token"));
- assert_eq!(chatgpt_key, None);
- }
-
- #[test]
- fn operations_briefing_ignores_blank_deepseek_key() {
- let key = operations_briefing_deepseek_api_key_for_provider(
- |name| (name == "DEEPSEEK_API_KEY").then_some(" ".to_string()),
- LargeModelProvider::DeepSeek,
- );
-
- assert_eq!(key, None);
- }
-
#[test]
fn deepseek_telemetry_with_pricing_sets_cost_estimate() {
let telemetry = DeepSeekChatTelemetry {
diff --git a/apps/desktop/src-tauri/src/kernel/app_update.rs b/apps/desktop/src-tauri/src/kernel/app_update.rs
index ab731d6..6999b50 100644
--- a/apps/desktop/src-tauri/src/kernel/app_update.rs
+++ b/apps/desktop/src-tauri/src/kernel/app_update.rs
@@ -17,8 +17,8 @@ pub(crate) const APP_UPDATE_RELEASES_API_URL: &str =
const APP_UPDATE_RELEASE_DOWNLOAD_PATH_PREFIX: &str = "/Lee-take/dsagent/releases/download/";
const APP_UPDATE_LEGACY_RELEASE_DOWNLOAD_PATH_PREFIX: &str =
"/Lee-take/deepseek-agent-os/releases/download/";
-const APP_UPDATE_USER_AGENT: &str = "DS-Agent-Updater/1.0.2";
-const APP_UPDATE_CURRENT_RELEASE_TAG: &str = "v1.0.2";
+const APP_UPDATE_USER_AGENT: &str = "DS-Agent-Updater/1.1.0";
+const APP_UPDATE_CURRENT_RELEASE_TAG: &str = "v1.1.0";
#[cfg(windows)]
const WINDOWS_CREATE_NO_WINDOW: u32 = 0x08000000;
@@ -653,7 +653,7 @@ mod tests {
let status = update_status_from_releases(releases, app_update_current_version());
assert!(!status.update_available);
- assert_eq!(status.current_version, "v1.0.2");
+ assert_eq!(status.current_version, "v1.1.0");
assert_eq!(status.latest_version.as_deref(), Some("0.3.0"));
assert!(status.asset_name.is_none());
}
diff --git a/apps/desktop/src-tauri/src/kernel/deepseek.rs b/apps/desktop/src-tauri/src/kernel/deepseek.rs
index 5cde9db..be409e6 100644
--- a/apps/desktop/src-tauri/src/kernel/deepseek.rs
+++ b/apps/desktop/src-tauri/src/kernel/deepseek.rs
@@ -6,8 +6,10 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
+use std::io::Read;
use std::sync::Mutex;
use std::time::Instant;
+use zeroize::{Zeroize, Zeroizing};
pub const DEEPSEEK_AUTO_LABEL: &str = "DeepSeek Auto";
pub const DEEPSEEK_FLASH_MODEL: &str = "deepseek-v4-flash";
@@ -15,20 +17,10 @@ pub const DEEPSEEK_PRO_MODEL: &str = "deepseek-v4-pro";
pub const DEEPSEEK_API_BASE_URL: &str = "https://api.deepseek.com";
pub const DEEPSEEK_CHAT_COMPLETIONS_PATH: &str = "/chat/completions";
pub const DEEPSEEK_USER_BALANCE_PATH: &str = "/user/balance";
+pub const DEEPSEEK_MODELS_PATH: &str = "/models";
pub const DEEPSEEK_API_KEY_ENV: &str = "DEEPSEEK_API_KEY";
pub const DEEPSEEK_CHAT_HTTP_TIMEOUT_SECS: u64 = 90;
-
-#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
-pub struct DeepSeekCredentialStatus {
- pub base_url: String,
- pub chat_completions_url: String,
- pub api_key_env_var: String,
- pub api_key_configured: bool,
- pub chat_completion_ready: bool,
- pub flash_model: String,
- pub pro_model: String,
- pub readiness_note: String,
-}
+const DEEPSEEK_RESPONSE_MAX_BYTES: usize = 256 * 1024;
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct DeepSeekUserBalanceInfo {
@@ -45,6 +37,41 @@ pub struct DeepSeekUserBalanceResponse {
pub balance_infos: Vec,
}
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
+pub struct DeepSeekModelDescriptor {
+ pub id: String,
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
+pub struct DeepSeekModelListResponse {
+ #[serde(default)]
+ pub data: Vec,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
+pub enum DeepSeekTransportFailure {
+ #[error("deepseek_http_status_{0}")]
+ HttpStatus(u16),
+ #[error("deepseek_network_unavailable")]
+ NetworkUnavailable,
+ #[error("deepseek_network_timeout")]
+ Timeout,
+ #[error("deepseek_provider_protocol_error")]
+ Protocol,
+}
+
+pub trait DeepSeekReadinessTransport {
+ fn fetch_user_balance(
+ &self,
+ api_key: &str,
+ ) -> Result;
+
+ fn fetch_models(
+ &self,
+ api_key: &str,
+ ) -> Result;
+}
+
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum DeepSeekChatRole {
@@ -229,10 +256,16 @@ pub struct DeepSeekOperationsBriefingSynthesizer<'a, T: DeepSeekChatCompletionTr
thinking: ThinkingLevel,
}
+impl Drop for DeepSeekOperationsBriefingSynthesizer<'_, T> {
+ fn drop(&mut self) {
+ self.api_key.zeroize();
+ }
+}
+
impl HttpDeepSeekChatCompletionTransport {
pub fn new() -> Result {
let client = reqwest::blocking::Client::builder()
- .user_agent("DeepSeek-Agent-OS/0.1.0 deepseek-chat")
+ .user_agent("DS-Agent/1.1.0 deepseek-v4")
.timeout(std::time::Duration::from_secs(
DEEPSEEK_CHAT_HTTP_TIMEOUT_SECS,
))
@@ -256,53 +289,99 @@ impl DeepSeekChatCompletionTransport for HttpDeepSeekChatCompletionTransport {
.bearer_auth(api_key)
.json(request)
.send()
- .map_err(|error| format!("deepseek chat request failed: {error}"))?;
+ .map_err(|error| safe_transport_failure(classify_reqwest_error(&error)))?;
let status = response.status();
- let body = response
- .text()
- .map_err(|error| format!("deepseek chat response could not be read: {error}"))?;
-
if !status.is_success() {
- return Err(format!(
- "deepseek chat request returned HTTP {}: {}",
- status.as_u16(),
- truncate_for_error(&body, 240)
+ return Err(safe_transport_failure(
+ DeepSeekTransportFailure::HttpStatus(status.as_u16()),
));
}
-
- serde_json::from_str::(&body)
- .map_err(|error| format!("deepseek chat response could not be parsed: {error}"))
+ let mut body = read_bounded_response(response).map_err(safe_transport_failure)?;
+ let parsed = serde_json::from_slice::(&body)
+ .map_err(|_| safe_transport_failure(DeepSeekTransportFailure::Protocol));
+ body.zeroize();
+ parsed
}
}
impl HttpDeepSeekChatCompletionTransport {
- pub fn get_user_balance(
+ fn fetch_json Deserialize<'de>>(
&self,
endpoint: &str,
api_key: &str,
- ) -> Result {
+ ) -> Result {
let response = self
.client
.get(endpoint)
.bearer_auth(api_key)
.send()
- .map_err(|error| format!("deepseek balance request failed: {error}"))?;
+ .map_err(|error| classify_reqwest_error(&error))?;
let status = response.status();
- let body = response
- .text()
- .map_err(|error| format!("deepseek balance response could not be read: {error}"))?;
-
if !status.is_success() {
- return Err(format!(
- "deepseek balance request returned HTTP {}: {}",
- status.as_u16(),
- truncate_for_error(&body, 240)
- ));
+ return Err(DeepSeekTransportFailure::HttpStatus(status.as_u16()));
}
+ let mut body = read_bounded_response(response)?;
+ let parsed = serde_json::from_slice(&body).map_err(|_| DeepSeekTransportFailure::Protocol);
+ body.zeroize();
+ parsed
+ }
+}
+
+impl DeepSeekReadinessTransport for HttpDeepSeekChatCompletionTransport {
+ fn fetch_user_balance(
+ &self,
+ api_key: &str,
+ ) -> Result {
+ self.fetch_json(
+ &format!("{DEEPSEEK_API_BASE_URL}{DEEPSEEK_USER_BALANCE_PATH}"),
+ api_key,
+ )
+ }
+
+ fn fetch_models(
+ &self,
+ api_key: &str,
+ ) -> Result {
+ self.fetch_json(
+ &format!("{DEEPSEEK_API_BASE_URL}{DEEPSEEK_MODELS_PATH}"),
+ api_key,
+ )
+ }
+}
+
+fn classify_reqwest_error(error: &reqwest::Error) -> DeepSeekTransportFailure {
+ if error.is_timeout() {
+ DeepSeekTransportFailure::Timeout
+ } else if error.is_connect() {
+ DeepSeekTransportFailure::NetworkUnavailable
+ } else {
+ DeepSeekTransportFailure::Protocol
+ }
+}
- serde_json::from_str::(&body)
- .map_err(|error| format!("deepseek balance response could not be parsed: {error}"))
+fn safe_transport_failure(error: DeepSeekTransportFailure) -> String {
+ error.to_string()
+}
+
+fn read_bounded_response(
+ mut response: reqwest::blocking::Response,
+) -> Result, DeepSeekTransportFailure> {
+ if response
+ .content_length()
+ .map(|length| length > DEEPSEEK_RESPONSE_MAX_BYTES as u64)
+ .unwrap_or(false)
+ {
+ return Err(DeepSeekTransportFailure::Protocol);
+ }
+ let mut body = Vec::new();
+ Read::take(&mut response, (DEEPSEEK_RESPONSE_MAX_BYTES + 1) as u64)
+ .read_to_end(&mut body)
+ .map_err(|_| DeepSeekTransportFailure::Protocol)?;
+ if body.len() > DEEPSEEK_RESPONSE_MAX_BYTES {
+ body.zeroize();
+ return Err(DeepSeekTransportFailure::Protocol);
}
+ Ok(body)
}
impl<'a, T: DeepSeekChatCompletionTransport> DeepSeekOperationsBriefingSynthesizer<'a, T> {
@@ -384,30 +463,6 @@ impl OperationsBriefingSynthesizer
}
}
-pub fn deepseek_credential_status_from_env(
- read_env: impl Fn(&str) -> Option,
-) -> DeepSeekCredentialStatus {
- let api_key_configured = read_env(DEEPSEEK_API_KEY_ENV)
- .map(|value| !value.trim().is_empty())
- .unwrap_or(false);
-
- DeepSeekCredentialStatus {
- base_url: DEEPSEEK_API_BASE_URL.to_string(),
- chat_completions_url: deepseek_chat_completions_url(),
- api_key_env_var: DEEPSEEK_API_KEY_ENV.to_string(),
- api_key_configured,
- chat_completion_ready: api_key_configured,
- flash_model: DEEPSEEK_FLASH_MODEL.to_string(),
- pro_model: DEEPSEEK_PRO_MODEL.to_string(),
- readiness_note: if api_key_configured {
- "DEEPSEEK_API_KEY is configured for Chat Completions requests".to_string()
- } else {
- "set DEEPSEEK_API_KEY in the local process environment to enable Chat Completions requests"
- .to_string()
- },
- }
-}
-
const OPERATIONS_BRIEFING_SYSTEM_PROMPT: &str = "You are an operations briefing analyst. Return strict JSON only. The JSON object must contain summary, anomalies, action_plan, and warnings. Do not invent evidence beyond the provided manifest.";
const OPERATIONS_BRIEFING_MAX_MANIFEST_CHARS: usize = 12_000;
@@ -453,10 +508,6 @@ fn truncate_for_prompt(value: &str, max_chars: usize) -> String {
output
}
-pub fn current_deepseek_credential_status() -> DeepSeekCredentialStatus {
- deepseek_credential_status_from_env(|name| std::env::var(name).ok())
-}
-
pub fn deepseek_chat_completions_url() -> String {
format!("{DEEPSEEK_API_BASE_URL}{DEEPSEEK_CHAT_COMPLETIONS_PATH}")
}
@@ -524,21 +575,6 @@ pub fn execute_deepseek_chat_completion(
.map_err(|error| redact_secret(&error, &api_key))
}
-pub fn execute_deepseek_user_balance(
- transport: &HttpDeepSeekChatCompletionTransport,
- api_key: &str,
-) -> Result {
- let api_key = api_key.trim().to_string();
- if api_key.is_empty() {
- return Err(format!("{DEEPSEEK_API_KEY_ENV} is required"));
- }
-
- let endpoint = format!("{DEEPSEEK_API_BASE_URL}{DEEPSEEK_USER_BALANCE_PATH}");
- transport
- .get_user_balance(&endpoint, &api_key)
- .map_err(|error| redact_secret(&error, &api_key))
-}
-
pub fn execute_deepseek_chat_completion_with_cache(
transport: &impl DeepSeekChatCompletionTransport,
cache: &(impl DeepSeekChatCompletionCache + ?Sized),
@@ -633,8 +669,8 @@ fn normalize_prompt(value: &str, label: &str) -> Result {
Ok(normalized)
}
-fn normalize_api_key(value: &str) -> Result {
- let normalized = value.trim().to_string();
+fn normalize_api_key(value: &str) -> Result, String> {
+ let normalized = Zeroizing::new(value.trim().to_string());
if normalized.is_empty() {
return Err(format!("{DEEPSEEK_API_KEY_ENV} is required"));
}
@@ -672,8 +708,7 @@ mod tests {
use std::thread::JoinHandle;
use super::{
- build_deepseek_chat_completion_request, deepseek_credential_status_from_env,
- effective_model, execute_deepseek_chat_completion,
+ build_deepseek_chat_completion_request, effective_model, execute_deepseek_chat_completion,
execute_deepseek_chat_completion_with_cache, thinking_budget_name, DeepSeekChatCacheStatus,
DeepSeekChatCompletionCache, DeepSeekChatCompletionResponse,
DeepSeekChatCompletionTransport, DeepSeekChatCompletionUsage,
@@ -837,38 +872,6 @@ mod tests {
assert_eq!(thinking_budget_name(ThinkingLevel::Deep), "max");
}
- #[test]
- fn credential_status_reports_missing_env_key_without_secret() {
- let status = deepseek_credential_status_from_env(|_| None);
-
- assert_eq!(status.base_url, DEEPSEEK_API_BASE_URL);
- assert_eq!(
- status.chat_completions_url,
- format!("{DEEPSEEK_API_BASE_URL}{DEEPSEEK_CHAT_COMPLETIONS_PATH}")
- );
- assert_eq!(status.api_key_env_var, DEEPSEEK_API_KEY_ENV);
- assert!(!status.api_key_configured);
- assert!(!status.chat_completion_ready);
- assert_eq!(status.flash_model, DEEPSEEK_FLASH_MODEL);
- assert_eq!(status.pro_model, DEEPSEEK_PRO_MODEL);
- }
-
- #[test]
- fn credential_status_reports_present_env_key_without_serializing_secret() {
- let status = deepseek_credential_status_from_env(|name| {
- if name == DEEPSEEK_API_KEY_ENV {
- Some("test-secret-token".to_string())
- } else {
- None
- }
- });
- let serialized = serde_json::to_string(&status).expect("status serializes");
-
- assert!(status.api_key_configured);
- assert!(status.chat_completion_ready);
- assert!(!serialized.contains("test-secret-token"));
- }
-
#[test]
fn chat_completion_request_uses_route_model_messages_and_deep_thinking() {
let request = build_deepseek_chat_completion_request(
@@ -957,7 +960,7 @@ mod tests {
)
.expect("request builds");
let response_body = serde_json::json!({
- "model": "deepseek-chat",
+ "model": "deepseek-v4-pro",
"choices": [
{
"message": {
@@ -979,7 +982,7 @@ mod tests {
assert_eq!(response.first_text(), Some("ok"));
assert!(recorded.raw.starts_with("POST / HTTP/1.1"));
- assert!(normalized_headers.contains("user-agent: deepseek-agent-os/0.1.0 deepseek-chat"));
+ assert!(normalized_headers.contains("user-agent: ds-agent/1.1.0 deepseek-v4"));
}
#[test]
diff --git a/apps/desktop/src-tauri/src/kernel/deepseek_credential.rs b/apps/desktop/src-tauri/src/kernel/deepseek_credential.rs
new file mode 100644
index 0000000..ef84316
--- /dev/null
+++ b/apps/desktop/src-tauri/src/kernel/deepseek_credential.rs
@@ -0,0 +1,1637 @@
+use std::fs::{self, OpenOptions};
+use std::io::{Read, Write};
+use std::path::{Path, PathBuf};
+use std::sync::Mutex;
+
+use chrono::{DateTime, Utc};
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+use uuid::Uuid;
+use zeroize::Zeroize;
+
+use crate::kernel::deepseek::{
+ DeepSeekReadinessTransport, DeepSeekTransportFailure, DEEPSEEK_API_BASE_URL,
+ DEEPSEEK_API_KEY_ENV, DEEPSEEK_FLASH_MODEL, DEEPSEEK_PRO_MODEL,
+};
+use crate::kernel::local_directory::{WorkspaceReadinessCode, WorkspaceReadinessProjection};
+
+const DEEPSEEK_KEY_MAX_BYTES: usize = 8 * 1024;
+const DEEPSEEK_PROTECTED_KEY_MAX_BYTES: usize = 32 * 1024;
+const DEEPSEEK_STATE_MAX_BYTES: usize = 32 * 1024;
+const DEEPSEEK_KEY_FILE: &str = "deepseek-api-key.credential";
+const DEEPSEEK_STATE_FILE: &str = "verification-state.json";
+
+#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum OnboardingOverallStatus {
+ SetupRequired,
+ Checking,
+ Blocked,
+ Ready,
+}
+
+#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum OnboardingNextStep {
+ DeepseekKey,
+ Workspace,
+ Doctor,
+ Ready,
+}
+
+#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum VersionReadinessStatus {
+ Current,
+ UpdateAvailable,
+ CheckUnavailable,
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub struct VersionReadinessProjection {
+ pub current_version: String,
+ pub status: VersionReadinessStatus,
+ pub blocking: bool,
+ pub message_key: String,
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub struct OnboardingReadinessProjection {
+ pub schema_version: u32,
+ pub overall: OnboardingOverallStatus,
+ pub next_step: OnboardingNextStep,
+ pub deepseek: DeepSeekReadinessProjection,
+ pub workspace: WorkspaceReadinessProjection,
+ pub version: VersionReadinessProjection,
+ pub checked_at: Option>,
+}
+
+pub fn build_onboarding_readiness_projection(
+ deepseek: DeepSeekReadinessProjection,
+ workspace: WorkspaceReadinessProjection,
+ current_version: impl Into,
+) -> OnboardingReadinessProjection {
+ let key_needs_input = matches!(
+ deepseek.code,
+ DeepSeekReadinessCode::KeyMissing
+ | DeepSeekReadinessCode::KeyFormatInvalid
+ | DeepSeekReadinessCode::AuthenticationFailed
+ );
+ let workspace_missing = workspace.code == WorkspaceReadinessCode::WorkspaceMissing;
+ let ready = deepseek.chat_completion_ready && workspace.code == WorkspaceReadinessCode::Ready;
+ let checking = deepseek.verification == DeepSeekVerificationState::Checking;
+ let overall = if ready {
+ OnboardingOverallStatus::Ready
+ } else if checking {
+ OnboardingOverallStatus::Checking
+ } else if key_needs_input || workspace_missing {
+ OnboardingOverallStatus::SetupRequired
+ } else {
+ OnboardingOverallStatus::Blocked
+ };
+ let next_step = if key_needs_input {
+ OnboardingNextStep::DeepseekKey
+ } else if deepseek.chat_completion_ready && workspace_missing {
+ OnboardingNextStep::Workspace
+ } else if ready {
+ OnboardingNextStep::Ready
+ } else {
+ OnboardingNextStep::Doctor
+ };
+ let checked_at = deepseek.last_verified_at;
+ OnboardingReadinessProjection {
+ schema_version: 1,
+ overall,
+ next_step,
+ deepseek,
+ workspace,
+ version: VersionReadinessProjection {
+ current_version: current_version.into(),
+ status: VersionReadinessStatus::Current,
+ blocking: false,
+ message_key: "onboarding.version.current".to_string(),
+ },
+ checked_at,
+ }
+}
+
+#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum DeepSeekCredentialSource {
+ Stored,
+ Environment,
+ Missing,
+}
+
+#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum DeepSeekVerificationState {
+ NotChecked,
+ Checking,
+ Verified,
+ Blocked,
+}
+
+#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum DeepSeekReadinessCode {
+ Ready,
+ NotChecked,
+ KeyMissing,
+ KeyFormatInvalid,
+ AuthenticationFailed,
+ InsufficientBalance,
+ RateLimited,
+ NetworkUnavailable,
+ NetworkTimeout,
+ ModelUnavailable,
+ RequestInvalid,
+ ProviderUnavailable,
+ ProviderProtocolError,
+ CredentialStoreUnavailable,
+}
+
+impl DeepSeekReadinessCode {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::Ready => "ready",
+ Self::NotChecked => "not_checked",
+ Self::KeyMissing => "key_missing",
+ Self::KeyFormatInvalid => "key_format_invalid",
+ Self::AuthenticationFailed => "authentication_failed",
+ Self::InsufficientBalance => "insufficient_balance",
+ Self::RateLimited => "rate_limited",
+ Self::NetworkUnavailable => "network_unavailable",
+ Self::NetworkTimeout => "network_timeout",
+ Self::ModelUnavailable => "model_unavailable",
+ Self::RequestInvalid => "request_invalid",
+ Self::ProviderUnavailable => "provider_unavailable",
+ Self::ProviderProtocolError => "provider_protocol_error",
+ Self::CredentialStoreUnavailable => "credential_store_unavailable",
+ }
+ }
+
+ pub fn retryable(self) -> bool {
+ matches!(
+ self,
+ Self::InsufficientBalance
+ | Self::RateLimited
+ | Self::NetworkUnavailable
+ | Self::NetworkTimeout
+ | Self::ModelUnavailable
+ | Self::ProviderUnavailable
+ | Self::ProviderProtocolError
+ )
+ }
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(default)]
+pub struct DeepSeekReadinessProjection {
+ pub source: DeepSeekCredentialSource,
+ pub configured: bool,
+ pub verification: DeepSeekVerificationState,
+ pub code: DeepSeekReadinessCode,
+ pub chat_completion_ready: bool,
+ pub balance_available: Option,
+ pub flash_model: String,
+ pub pro_model: String,
+ pub flash_available: Option,
+ pub pro_available: Option,
+ pub retryable: bool,
+ pub last_verified_at: Option>,
+ pub message_key: String,
+}
+
+impl Default for DeepSeekReadinessProjection {
+ fn default() -> Self {
+ Self::missing()
+ }
+}
+
+impl DeepSeekReadinessProjection {
+ fn missing() -> Self {
+ Self::for_code(
+ DeepSeekCredentialSource::Missing,
+ DeepSeekVerificationState::NotChecked,
+ DeepSeekReadinessCode::KeyMissing,
+ None,
+ )
+ }
+
+ fn for_code(
+ source: DeepSeekCredentialSource,
+ verification: DeepSeekVerificationState,
+ code: DeepSeekReadinessCode,
+ receipt: Option<&DeepSeekVerificationReceipt>,
+ ) -> Self {
+ let ready = verification == DeepSeekVerificationState::Verified
+ && code == DeepSeekReadinessCode::Ready;
+ Self {
+ source,
+ configured: source != DeepSeekCredentialSource::Missing,
+ verification,
+ code,
+ chat_completion_ready: ready,
+ balance_available: receipt.and_then(|value| value.balance_available),
+ flash_model: DEEPSEEK_FLASH_MODEL.to_string(),
+ pro_model: DEEPSEEK_PRO_MODEL.to_string(),
+ flash_available: receipt.and_then(|value| value.flash_available),
+ pro_available: receipt.and_then(|value| value.pro_available),
+ retryable: code.retryable(),
+ last_verified_at: receipt.map(|value| value.verified_at),
+ message_key: format!("onboarding.deepseek.{}", code.as_str()),
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum DeepSeekVerificationReceiptStatus {
+ Verified,
+ Blocked,
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub struct DeepSeekVerificationReceipt {
+ pub schema_version: u32,
+ pub credential_revision: u64,
+ pub status: DeepSeekVerificationReceiptStatus,
+ pub code: DeepSeekReadinessCode,
+ pub balance_available: Option,
+ pub flash_available: Option,
+ pub pro_available: Option,
+ pub verified_at: DateTime,
+ pub endpoint_origin: String,
+}
+
+impl DeepSeekVerificationReceipt {
+ fn blocked(
+ credential_revision: u64,
+ code: DeepSeekReadinessCode,
+ balance_available: Option,
+ flash_available: Option,
+ pro_available: Option,
+ ) -> Self {
+ Self {
+ schema_version: 1,
+ credential_revision,
+ status: DeepSeekVerificationReceiptStatus::Blocked,
+ code,
+ balance_available,
+ flash_available,
+ pro_available,
+ verified_at: Utc::now(),
+ endpoint_origin: DEEPSEEK_API_BASE_URL.to_string(),
+ }
+ }
+
+ fn verified(credential_revision: u64, flash_available: bool, pro_available: bool) -> Self {
+ Self {
+ schema_version: 1,
+ credential_revision,
+ status: DeepSeekVerificationReceiptStatus::Verified,
+ code: DeepSeekReadinessCode::Ready,
+ balance_available: Some(true),
+ flash_available: Some(flash_available),
+ pro_available: Some(pro_available),
+ verified_at: Utc::now(),
+ endpoint_origin: DEEPSEEK_API_BASE_URL.to_string(),
+ }
+ }
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+struct DeepSeekCredentialState {
+ schema_version: u32,
+ credential_revision: u64,
+ receipt: Option,
+}
+
+impl Default for DeepSeekCredentialState {
+ fn default() -> Self {
+ Self {
+ schema_version: 1,
+ credential_revision: 0,
+ receipt: None,
+ }
+ }
+}
+
+pub struct DeepSeekSecret(String);
+
+impl DeepSeekSecret {
+ pub fn new(mut value: String) -> Result {
+ let normalized = value.trim().to_string();
+ value.zeroize();
+ if normalized.is_empty() || normalized.len() > DEEPSEEK_KEY_MAX_BYTES {
+ let mut normalized = normalized;
+ normalized.zeroize();
+ return Err(DeepSeekReadinessCode::KeyFormatInvalid);
+ }
+ Ok(Self(normalized))
+ }
+
+ pub(crate) fn expose(&self) -> &str {
+ &self.0
+ }
+}
+
+impl Drop for DeepSeekSecret {
+ fn drop(&mut self) {
+ self.0.zeroize();
+ }
+}
+
+pub trait DeepSeekCredentialStore: Send {
+ fn put(&mut self, secret: DeepSeekSecret) -> Result<(), DeepSeekReadinessCode>;
+ fn read(&self) -> Result;
+ fn delete(&mut self) -> Result<(), DeepSeekReadinessCode>;
+ fn contains(&self) -> Result;
+}
+
+pub struct FileDeepSeekCredentialStateStore {
+ root: PathBuf,
+}
+
+impl FileDeepSeekCredentialStateStore {
+ pub fn new(root: impl AsRef) -> Result {
+ fs::create_dir_all(root.as_ref())
+ .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?;
+ let root = fs::canonicalize(root.as_ref())
+ .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?;
+ if !root.is_dir() {
+ return Err(DeepSeekReadinessCode::CredentialStoreUnavailable);
+ }
+ cleanup_staged_files(&root, ".deepseek-state-")?;
+ Ok(Self { root })
+ }
+
+ fn path(&self) -> PathBuf {
+ self.root.join(DEEPSEEK_STATE_FILE)
+ }
+
+ fn load(&self) -> Result {
+ let path = self.path();
+ let metadata = match fs::symlink_metadata(&path) {
+ Ok(metadata) => metadata,
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
+ return Ok(DeepSeekCredentialState::default())
+ }
+ Err(_) => return Err(DeepSeekReadinessCode::CredentialStoreUnavailable),
+ };
+ if metadata.file_type().is_symlink()
+ || !metadata.is_file()
+ || metadata.len() as usize > DEEPSEEK_STATE_MAX_BYTES
+ {
+ return Err(DeepSeekReadinessCode::CredentialStoreUnavailable);
+ }
+ let canonical = fs::canonicalize(&path)
+ .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?;
+ if !canonical.starts_with(&self.root) {
+ return Err(DeepSeekReadinessCode::CredentialStoreUnavailable);
+ }
+ let mut file = OpenOptions::new()
+ .read(true)
+ .open(canonical)
+ .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?;
+ let mut bytes = Vec::new();
+ Read::take(&mut file, (DEEPSEEK_STATE_MAX_BYTES + 1) as u64)
+ .read_to_end(&mut bytes)
+ .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?;
+ if bytes.len() > DEEPSEEK_STATE_MAX_BYTES {
+ bytes.zeroize();
+ return Err(DeepSeekReadinessCode::CredentialStoreUnavailable);
+ }
+ let parsed = serde_json::from_slice::(&bytes)
+ .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable);
+ bytes.zeroize();
+ let state = parsed?;
+ if state.schema_version != 1 {
+ return Err(DeepSeekReadinessCode::CredentialStoreUnavailable);
+ }
+ Ok(state)
+ }
+
+ fn save(&self, state: &DeepSeekCredentialState) -> Result<(), DeepSeekReadinessCode> {
+ let bytes = serde_json::to_vec_pretty(state)
+ .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?;
+ if bytes.len() > DEEPSEEK_STATE_MAX_BYTES {
+ return Err(DeepSeekReadinessCode::CredentialStoreUnavailable);
+ }
+ atomic_write(&self.root, &self.path(), ".deepseek-state-", &bytes)
+ }
+
+ fn delete(&self) -> Result<(), DeepSeekReadinessCode> {
+ remove_regular_file_if_present(&self.path())
+ }
+}
+
+struct EnvironmentReceipt {
+ key_digest: [u8; 32],
+ receipt: DeepSeekVerificationReceipt,
+}
+
+impl Drop for EnvironmentReceipt {
+ fn drop(&mut self) {
+ self.key_digest.zeroize();
+ }
+}
+
+pub struct DeepSeekCredentialRuntime {
+ store: Mutex,
+ state_store: FileDeepSeekCredentialStateStore,
+ lifecycle: Mutex<()>,
+ environment_receipt: Mutex>,
+}
+
+impl DeepSeekCredentialRuntime {
+ pub fn new(store: S, state_store: FileDeepSeekCredentialStateStore) -> Self {
+ Self {
+ store: Mutex::new(store),
+ state_store,
+ lifecycle: Mutex::new(()),
+ environment_receipt: Mutex::new(None),
+ }
+ }
+
+ pub fn projection(
+ &self,
+ read_env: impl Fn(&str) -> Option,
+ ) -> DeepSeekReadinessProjection {
+ let _lifecycle = match self.lifecycle.lock() {
+ Ok(lock) => lock,
+ Err(_) => return credential_store_unavailable_projection(),
+ };
+ self.projection_locked(read_env)
+ }
+
+ fn projection_locked(
+ &self,
+ read_env: impl Fn(&str) -> Option,
+ ) -> DeepSeekReadinessProjection {
+ let has_stored = match self.store.lock() {
+ Ok(store) => match store.contains() {
+ Ok(contains) => contains,
+ Err(_) => return credential_store_unavailable_projection(),
+ },
+ Err(_) => return credential_store_unavailable_projection(),
+ };
+ if has_stored {
+ let state = match self.state_store.load() {
+ Ok(state) if state.credential_revision > 0 => state,
+ _ => return credential_store_unavailable_projection(),
+ };
+ return projection_from_receipt(
+ DeepSeekCredentialSource::Stored,
+ state.credential_revision,
+ state.receipt.as_ref(),
+ );
+ }
+
+ let Some(secret) = environment_secret(read_env) else {
+ return DeepSeekReadinessProjection::missing();
+ };
+ let digest = secret_digest(&secret);
+ let projection = self
+ .environment_receipt
+ .lock()
+ .ok()
+ .and_then(|receipt| {
+ receipt
+ .as_ref()
+ .filter(|receipt| receipt.key_digest == digest)
+ .map(|receipt| {
+ projection_from_receipt(
+ DeepSeekCredentialSource::Environment,
+ receipt.receipt.credential_revision,
+ Some(&receipt.receipt),
+ )
+ })
+ })
+ .unwrap_or_else(|| {
+ DeepSeekReadinessProjection::for_code(
+ DeepSeekCredentialSource::Environment,
+ DeepSeekVerificationState::NotChecked,
+ DeepSeekReadinessCode::NotChecked,
+ None,
+ )
+ });
+ drop(secret);
+ projection
+ }
+
+ pub fn save_and_verify(
+ &self,
+ api_key: String,
+ verifier: &impl DeepSeekReadinessTransport,
+ read_env: impl Fn(&str) -> Option + Copy,
+ ) -> DeepSeekReadinessProjection {
+ let secret = match DeepSeekSecret::new(api_key) {
+ Ok(secret) => secret,
+ Err(code) => {
+ let source = self.projection(read_env).source;
+ return DeepSeekReadinessProjection::for_code(
+ source,
+ DeepSeekVerificationState::Blocked,
+ code,
+ None,
+ );
+ }
+ };
+ let revision = {
+ let _lifecycle = match self.lifecycle.lock() {
+ Ok(lock) => lock,
+ Err(_) => return credential_store_unavailable_projection(),
+ };
+ let mut state = match self.state_store.load() {
+ Ok(state) => state,
+ Err(_) => return credential_store_unavailable_projection(),
+ };
+ let mut store = match self.store.lock() {
+ Ok(store) => store,
+ Err(_) => return credential_store_unavailable_projection(),
+ };
+ state.credential_revision = state.credential_revision.saturating_add(1).max(1);
+ state.receipt = None;
+ if self.state_store.save(&state).is_err() {
+ return credential_store_unavailable_projection();
+ }
+ if let Ok(mut receipt) = self.environment_receipt.lock() {
+ *receipt = None;
+ }
+ if store.put(secret).is_err() {
+ return credential_store_unavailable_projection();
+ }
+ state.credential_revision
+ };
+ self.verify_stored_revision(revision, verifier, read_env)
+ }
+
+ pub fn verify(
+ &self,
+ verifier: &impl DeepSeekReadinessTransport,
+ read_env: impl Fn(&str) -> Option + Copy,
+ ) -> DeepSeekReadinessProjection {
+ let source = {
+ let _lifecycle = match self.lifecycle.lock() {
+ Ok(lock) => lock,
+ Err(_) => return credential_store_unavailable_projection(),
+ };
+ let has_stored = match self.store.lock() {
+ Ok(store) => match store.contains() {
+ Ok(contains) => contains,
+ Err(_) => return credential_store_unavailable_projection(),
+ },
+ Err(_) => return credential_store_unavailable_projection(),
+ };
+ if has_stored {
+ let state = match self.state_store.load() {
+ Ok(state) if state.credential_revision > 0 => state,
+ _ => return credential_store_unavailable_projection(),
+ };
+ Some((DeepSeekCredentialSource::Stored, state.credential_revision))
+ } else if environment_secret(read_env).is_some() {
+ Some((DeepSeekCredentialSource::Environment, 0))
+ } else {
+ None
+ }
+ };
+ match source {
+ Some((DeepSeekCredentialSource::Stored, revision)) => {
+ self.verify_stored_revision(revision, verifier, read_env)
+ }
+ Some((DeepSeekCredentialSource::Environment, _)) => {
+ self.verify_environment(verifier, read_env)
+ }
+ _ => DeepSeekReadinessProjection::missing(),
+ }
+ }
+
+ fn verify_stored_revision(
+ &self,
+ revision: u64,
+ verifier: &impl DeepSeekReadinessTransport,
+ read_env: impl Fn(&str) -> Option + Copy,
+ ) -> DeepSeekReadinessProjection {
+ let secret = {
+ let store = match self.store.lock() {
+ Ok(store) => store,
+ Err(_) => return credential_store_unavailable_projection(),
+ };
+ match store.read() {
+ Ok(secret) => secret,
+ Err(_) => return credential_store_unavailable_projection(),
+ }
+ };
+ let receipt = run_verification(verifier, &secret, revision);
+ drop(secret);
+ let _lifecycle = match self.lifecycle.lock() {
+ Ok(lock) => lock,
+ Err(_) => return credential_store_unavailable_projection(),
+ };
+ let mut state = match self.state_store.load() {
+ Ok(state) => state,
+ Err(_) => return credential_store_unavailable_projection(),
+ };
+ if state.credential_revision != revision {
+ return self.projection_locked(read_env);
+ }
+ state.receipt = Some(receipt.clone());
+ if self.state_store.save(&state).is_err() {
+ return credential_store_unavailable_projection();
+ }
+ projection_from_receipt(DeepSeekCredentialSource::Stored, revision, Some(&receipt))
+ }
+
+ fn verify_environment(
+ &self,
+ verifier: &impl DeepSeekReadinessTransport,
+ read_env: impl Fn(&str) -> Option + Copy,
+ ) -> DeepSeekReadinessProjection {
+ let Some(secret) = environment_secret(read_env) else {
+ return DeepSeekReadinessProjection::missing();
+ };
+ let digest = secret_digest(&secret);
+ let receipt = run_verification(verifier, &secret, 0);
+ drop(secret);
+ let Some(current_secret) = environment_secret(read_env) else {
+ return DeepSeekReadinessProjection::missing();
+ };
+ let current_digest = secret_digest(¤t_secret);
+ drop(current_secret);
+ if current_digest != digest {
+ return DeepSeekReadinessProjection::for_code(
+ DeepSeekCredentialSource::Environment,
+ DeepSeekVerificationState::NotChecked,
+ DeepSeekReadinessCode::NotChecked,
+ None,
+ );
+ }
+ match self.environment_receipt.lock() {
+ Ok(mut environment_receipt) => {
+ *environment_receipt = Some(EnvironmentReceipt {
+ key_digest: digest,
+ receipt: receipt.clone(),
+ });
+ }
+ Err(_) => return credential_store_unavailable_projection(),
+ }
+ projection_from_receipt(DeepSeekCredentialSource::Environment, 0, Some(&receipt))
+ }
+
+ pub fn remove(&self, read_env: impl Fn(&str) -> Option) -> DeepSeekReadinessProjection {
+ let _lifecycle = match self.lifecycle.lock() {
+ Ok(lock) => lock,
+ Err(_) => return credential_store_unavailable_projection(),
+ };
+ let mut store = match self.store.lock() {
+ Ok(store) => store,
+ Err(_) => return credential_store_unavailable_projection(),
+ };
+ if store.delete().is_err() || self.state_store.delete().is_err() {
+ return credential_store_unavailable_projection();
+ }
+ if let Ok(mut receipt) = self.environment_receipt.lock() {
+ *receipt = None;
+ }
+ drop(store);
+ self.projection_locked(read_env)
+ }
+
+ pub fn resolve_ready_key(
+ &self,
+ read_env: impl Fn(&str) -> Option,
+ ) -> Result {
+ let _lifecycle = self
+ .lifecycle
+ .lock()
+ .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?;
+ let has_stored = self
+ .store
+ .lock()
+ .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?
+ .contains()?;
+ if has_stored {
+ let state = self.state_store.load()?;
+ let ready = state
+ .receipt
+ .as_ref()
+ .filter(|receipt| receipt.credential_revision == state.credential_revision)
+ .map(|receipt| {
+ receipt.status == DeepSeekVerificationReceiptStatus::Verified
+ && receipt.code == DeepSeekReadinessCode::Ready
+ })
+ .unwrap_or(false);
+ if !ready {
+ return Err(state
+ .receipt
+ .as_ref()
+ .map(|receipt| receipt.code)
+ .unwrap_or(DeepSeekReadinessCode::NotChecked));
+ }
+ return self
+ .store
+ .lock()
+ .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?
+ .read();
+ }
+ let secret = environment_secret(read_env).ok_or(DeepSeekReadinessCode::KeyMissing)?;
+ let digest = secret_digest(&secret);
+ let ready = self
+ .environment_receipt
+ .lock()
+ .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?
+ .as_ref()
+ .filter(|receipt| receipt.key_digest == digest)
+ .map(|receipt| {
+ receipt.receipt.status == DeepSeekVerificationReceiptStatus::Verified
+ && receipt.receipt.code == DeepSeekReadinessCode::Ready
+ })
+ .unwrap_or(false);
+ if ready {
+ Ok(secret)
+ } else {
+ Err(DeepSeekReadinessCode::NotChecked)
+ }
+ }
+}
+
+fn projection_from_receipt(
+ source: DeepSeekCredentialSource,
+ revision: u64,
+ receipt: Option<&DeepSeekVerificationReceipt>,
+) -> DeepSeekReadinessProjection {
+ let receipt = receipt.filter(|receipt| receipt.credential_revision == revision);
+ match receipt {
+ Some(receipt) if receipt.status == DeepSeekVerificationReceiptStatus::Verified => {
+ DeepSeekReadinessProjection::for_code(
+ source,
+ DeepSeekVerificationState::Verified,
+ receipt.code,
+ Some(receipt),
+ )
+ }
+ Some(receipt) => DeepSeekReadinessProjection::for_code(
+ source,
+ DeepSeekVerificationState::Blocked,
+ receipt.code,
+ Some(receipt),
+ ),
+ None => DeepSeekReadinessProjection::for_code(
+ source,
+ DeepSeekVerificationState::NotChecked,
+ DeepSeekReadinessCode::NotChecked,
+ None,
+ ),
+ }
+}
+
+fn run_verification(
+ verifier: &impl DeepSeekReadinessTransport,
+ secret: &DeepSeekSecret,
+ revision: u64,
+) -> DeepSeekVerificationReceipt {
+ let balance = match verifier.fetch_user_balance(secret.expose()) {
+ Ok(balance) => balance,
+ Err(error) => {
+ return DeepSeekVerificationReceipt::blocked(
+ revision,
+ readiness_code_from_transport(error, false),
+ None,
+ None,
+ None,
+ )
+ }
+ };
+ if !balance.is_available {
+ return DeepSeekVerificationReceipt::blocked(
+ revision,
+ DeepSeekReadinessCode::InsufficientBalance,
+ Some(false),
+ None,
+ None,
+ );
+ }
+ let models = match verifier.fetch_models(secret.expose()) {
+ Ok(models) => models,
+ Err(error) => {
+ return DeepSeekVerificationReceipt::blocked(
+ revision,
+ readiness_code_from_transport(error, true),
+ Some(true),
+ None,
+ None,
+ )
+ }
+ };
+ let flash_available = models
+ .data
+ .iter()
+ .any(|model| model.id == DEEPSEEK_FLASH_MODEL);
+ let pro_available = models
+ .data
+ .iter()
+ .any(|model| model.id == DEEPSEEK_PRO_MODEL);
+ if !flash_available || !pro_available {
+ return DeepSeekVerificationReceipt::blocked(
+ revision,
+ DeepSeekReadinessCode::ModelUnavailable,
+ Some(true),
+ Some(flash_available),
+ Some(pro_available),
+ );
+ }
+ DeepSeekVerificationReceipt::verified(revision, flash_available, pro_available)
+}
+
+fn readiness_code_from_transport(
+ failure: DeepSeekTransportFailure,
+ during_models: bool,
+) -> DeepSeekReadinessCode {
+ match failure {
+ DeepSeekTransportFailure::HttpStatus(401) => DeepSeekReadinessCode::AuthenticationFailed,
+ DeepSeekTransportFailure::HttpStatus(402) => DeepSeekReadinessCode::InsufficientBalance,
+ DeepSeekTransportFailure::HttpStatus(429) => DeepSeekReadinessCode::RateLimited,
+ DeepSeekTransportFailure::HttpStatus(400 | 422) => DeepSeekReadinessCode::RequestInvalid,
+ DeepSeekTransportFailure::HttpStatus(500 | 503) => {
+ DeepSeekReadinessCode::ProviderUnavailable
+ }
+ DeepSeekTransportFailure::HttpStatus(404) if during_models => {
+ DeepSeekReadinessCode::ModelUnavailable
+ }
+ DeepSeekTransportFailure::NetworkUnavailable => DeepSeekReadinessCode::NetworkUnavailable,
+ DeepSeekTransportFailure::Timeout => DeepSeekReadinessCode::NetworkTimeout,
+ DeepSeekTransportFailure::Protocol | DeepSeekTransportFailure::HttpStatus(_) => {
+ DeepSeekReadinessCode::ProviderProtocolError
+ }
+ }
+}
+
+fn credential_store_unavailable_projection() -> DeepSeekReadinessProjection {
+ DeepSeekReadinessProjection::for_code(
+ DeepSeekCredentialSource::Missing,
+ DeepSeekVerificationState::Blocked,
+ DeepSeekReadinessCode::CredentialStoreUnavailable,
+ None,
+ )
+}
+
+fn environment_secret(read_env: impl Fn(&str) -> Option) -> Option {
+ read_env(DEEPSEEK_API_KEY_ENV).and_then(|value| DeepSeekSecret::new(value).ok())
+}
+
+fn secret_digest(secret: &DeepSeekSecret) -> [u8; 32] {
+ Sha256::digest(secret.expose().as_bytes()).into()
+}
+
+fn cleanup_staged_files(root: &Path, prefix: &str) -> Result<(), DeepSeekReadinessCode> {
+ let mut entries_seen = 0usize;
+ let mut staged_seen = 0usize;
+ for entry in
+ fs::read_dir(root).map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?
+ {
+ let entry = entry.map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?;
+ entries_seen += 1;
+ if entries_seen > 4096 {
+ return Err(DeepSeekReadinessCode::CredentialStoreUnavailable);
+ }
+ let name = entry.file_name();
+ let name = name.to_string_lossy();
+ if !name.starts_with(prefix) || !name.ends_with(".tmp") {
+ continue;
+ }
+ staged_seen += 1;
+ if staged_seen > 64 {
+ return Err(DeepSeekReadinessCode::CredentialStoreUnavailable);
+ }
+ let metadata = fs::symlink_metadata(entry.path())
+ .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?;
+ if metadata.file_type().is_symlink() || !metadata.is_file() {
+ return Err(DeepSeekReadinessCode::CredentialStoreUnavailable);
+ }
+ fs::remove_file(entry.path())
+ .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?;
+ }
+ Ok(())
+}
+
+fn atomic_write(
+ root: &Path,
+ destination: &Path,
+ prefix: &str,
+ bytes: &[u8],
+) -> Result<(), DeepSeekReadinessCode> {
+ if let Ok(metadata) = fs::symlink_metadata(destination) {
+ if metadata.file_type().is_symlink() || !metadata.is_file() {
+ return Err(DeepSeekReadinessCode::CredentialStoreUnavailable);
+ }
+ }
+ let temp = root.join(format!("{prefix}{}.tmp", Uuid::new_v4()));
+ let result = (|| {
+ let mut file = OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .open(&temp)
+ .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?;
+ file.write_all(bytes)
+ .and_then(|_| file.sync_all())
+ .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?;
+ drop(file);
+ atomic_replace(&temp, destination)
+ })();
+ if result.is_err() {
+ let _ = fs::remove_file(&temp);
+ }
+ result
+}
+
+#[cfg(windows)]
+fn atomic_replace(source: &Path, destination: &Path) -> Result<(), DeepSeekReadinessCode> {
+ use std::os::windows::ffi::OsStrExt;
+ use windows::core::PCWSTR;
+ use windows::Win32::Storage::FileSystem::{
+ MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH,
+ };
+
+ let source_wide = source
+ .as_os_str()
+ .encode_wide()
+ .chain(std::iter::once(0))
+ .collect::>();
+ let destination_wide = destination
+ .as_os_str()
+ .encode_wide()
+ .chain(std::iter::once(0))
+ .collect::>();
+ unsafe {
+ MoveFileExW(
+ PCWSTR(source_wide.as_ptr()),
+ PCWSTR(destination_wide.as_ptr()),
+ MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
+ )
+ }
+ .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)
+}
+
+#[cfg(not(windows))]
+fn atomic_replace(source: &Path, destination: &Path) -> Result<(), DeepSeekReadinessCode> {
+ fs::rename(source, destination).map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)
+}
+
+fn remove_regular_file_if_present(path: &Path) -> Result<(), DeepSeekReadinessCode> {
+ match fs::symlink_metadata(path) {
+ Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
+ Err(DeepSeekReadinessCode::CredentialStoreUnavailable)
+ }
+ Ok(_) => {
+ fs::remove_file(path).map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)
+ }
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
+ Err(_) => Err(DeepSeekReadinessCode::CredentialStoreUnavailable),
+ }
+}
+
+#[cfg(windows)]
+pub struct WindowsDeepSeekCredentialStore {
+ root: PathBuf,
+}
+
+#[cfg(windows)]
+impl WindowsDeepSeekCredentialStore {
+ pub fn new(root: impl AsRef) -> Result {
+ fs::create_dir_all(root.as_ref())
+ .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?;
+ let root = fs::canonicalize(root.as_ref())
+ .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?;
+ if !root.is_dir() {
+ return Err(DeepSeekReadinessCode::CredentialStoreUnavailable);
+ }
+ cleanup_staged_files(&root, ".deepseek-key-")?;
+ Ok(Self { root })
+ }
+
+ fn path(&self) -> PathBuf {
+ self.root.join(DEEPSEEK_KEY_FILE)
+ }
+
+ fn entropy() -> [u8; 32] {
+ Sha256::digest(b"ds-agent.deepseek-credential.v1").into()
+ }
+
+ fn protect(secret: &DeepSeekSecret) -> Result, DeepSeekReadinessCode> {
+ use windows::core::w;
+ use windows::Win32::Foundation::{LocalFree, HLOCAL};
+ use windows::Win32::Security::Cryptography::{
+ CryptProtectData, CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB,
+ };
+
+ let mut plaintext = secret.expose().as_bytes().to_vec();
+ let input = CRYPT_INTEGER_BLOB {
+ cbData: plaintext.len() as u32,
+ pbData: plaintext.as_mut_ptr(),
+ };
+ let mut entropy = Self::entropy();
+ let entropy_blob = CRYPT_INTEGER_BLOB {
+ cbData: entropy.len() as u32,
+ pbData: entropy.as_mut_ptr(),
+ };
+ let mut output = CRYPT_INTEGER_BLOB::default();
+ let result = unsafe {
+ CryptProtectData(
+ &input,
+ w!("DS Agent DeepSeek credential"),
+ Some(&entropy_blob),
+ None,
+ None,
+ CRYPTPROTECT_UI_FORBIDDEN,
+ &mut output,
+ )
+ };
+ plaintext.zeroize();
+ entropy.zeroize();
+ result.map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?;
+ if output.pbData.is_null() || output.cbData as usize > DEEPSEEK_PROTECTED_KEY_MAX_BYTES {
+ if !output.pbData.is_null() {
+ let protected = unsafe {
+ std::slice::from_raw_parts_mut(output.pbData, output.cbData as usize)
+ };
+ protected.zeroize();
+ unsafe { LocalFree(Some(HLOCAL(output.pbData.cast()))) };
+ }
+ return Err(DeepSeekReadinessCode::CredentialStoreUnavailable);
+ }
+ let protected =
+ unsafe { std::slice::from_raw_parts_mut(output.pbData, output.cbData as usize) };
+ let value = protected.to_vec();
+ protected.zeroize();
+ unsafe { LocalFree(Some(HLOCAL(output.pbData.cast()))) };
+ Ok(value)
+ }
+
+ fn unprotect(mut protected: Vec) -> Result {
+ use windows::Win32::Foundation::{LocalFree, HLOCAL};
+ use windows::Win32::Security::Cryptography::{
+ CryptUnprotectData, CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB,
+ };
+
+ if protected.is_empty() || protected.len() > DEEPSEEK_PROTECTED_KEY_MAX_BYTES {
+ protected.zeroize();
+ return Err(DeepSeekReadinessCode::CredentialStoreUnavailable);
+ }
+ let input = CRYPT_INTEGER_BLOB {
+ cbData: protected.len() as u32,
+ pbData: protected.as_mut_ptr(),
+ };
+ let mut entropy = Self::entropy();
+ let entropy_blob = CRYPT_INTEGER_BLOB {
+ cbData: entropy.len() as u32,
+ pbData: entropy.as_mut_ptr(),
+ };
+ let mut output = CRYPT_INTEGER_BLOB::default();
+ let result = unsafe {
+ CryptUnprotectData(
+ &input,
+ None,
+ Some(&entropy_blob),
+ None,
+ None,
+ CRYPTPROTECT_UI_FORBIDDEN,
+ &mut output,
+ )
+ };
+ protected.zeroize();
+ entropy.zeroize();
+ result.map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?;
+ if output.pbData.is_null() || output.cbData as usize > DEEPSEEK_KEY_MAX_BYTES {
+ if !output.pbData.is_null() {
+ let plaintext = unsafe {
+ std::slice::from_raw_parts_mut(output.pbData, output.cbData as usize)
+ };
+ plaintext.zeroize();
+ unsafe { LocalFree(Some(HLOCAL(output.pbData.cast()))) };
+ }
+ return Err(DeepSeekReadinessCode::CredentialStoreUnavailable);
+ }
+ let plaintext =
+ unsafe { std::slice::from_raw_parts_mut(output.pbData, output.cbData as usize) };
+ let value = String::from_utf8(plaintext.to_vec());
+ plaintext.zeroize();
+ unsafe { LocalFree(Some(HLOCAL(output.pbData.cast()))) };
+ match value {
+ Ok(value) => DeepSeekSecret::new(value),
+ Err(error) => {
+ let mut bytes = error.into_bytes();
+ bytes.zeroize();
+ Err(DeepSeekReadinessCode::CredentialStoreUnavailable)
+ }
+ }
+ }
+
+ fn read_protected(&self) -> Result, DeepSeekReadinessCode> {
+ let path = self.path();
+ let metadata = fs::symlink_metadata(&path)
+ .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?;
+ if metadata.file_type().is_symlink()
+ || !metadata.is_file()
+ || metadata.len() as usize > DEEPSEEK_PROTECTED_KEY_MAX_BYTES
+ {
+ return Err(DeepSeekReadinessCode::CredentialStoreUnavailable);
+ }
+ let canonical = fs::canonicalize(&path)
+ .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?;
+ if !canonical.starts_with(&self.root) {
+ return Err(DeepSeekReadinessCode::CredentialStoreUnavailable);
+ }
+ let mut file = OpenOptions::new()
+ .read(true)
+ .open(canonical)
+ .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?;
+ let mut protected = Vec::new();
+ Read::take(&mut file, (DEEPSEEK_PROTECTED_KEY_MAX_BYTES + 1) as u64)
+ .read_to_end(&mut protected)
+ .map_err(|_| DeepSeekReadinessCode::CredentialStoreUnavailable)?;
+ if protected.len() > DEEPSEEK_PROTECTED_KEY_MAX_BYTES {
+ protected.zeroize();
+ return Err(DeepSeekReadinessCode::CredentialStoreUnavailable);
+ }
+ Ok(protected)
+ }
+}
+
+#[cfg(windows)]
+impl DeepSeekCredentialStore for WindowsDeepSeekCredentialStore {
+ fn put(&mut self, secret: DeepSeekSecret) -> Result<(), DeepSeekReadinessCode> {
+ let mut protected = Self::protect(&secret)?;
+ let result = atomic_write(&self.root, &self.path(), ".deepseek-key-", &protected);
+ protected.zeroize();
+ result
+ }
+
+ fn read(&self) -> Result {
+ Self::unprotect(self.read_protected()?)
+ }
+
+ fn delete(&mut self) -> Result<(), DeepSeekReadinessCode> {
+ remove_regular_file_if_present(&self.path())
+ }
+
+ fn contains(&self) -> Result {
+ match fs::symlink_metadata(self.path()) {
+ Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => Ok(true),
+ Ok(_) => Err(DeepSeekReadinessCode::CredentialStoreUnavailable),
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
+ Err(_) => Err(DeepSeekReadinessCode::CredentialStoreUnavailable),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::collections::VecDeque;
+ use std::sync::atomic::{AtomicBool, Ordering};
+ use std::sync::Arc;
+
+ use crate::kernel::deepseek::{
+ DeepSeekModelDescriptor, DeepSeekModelListResponse, DeepSeekReadinessTransport,
+ DeepSeekTransportFailure, DeepSeekUserBalanceResponse,
+ };
+
+ use super::*;
+
+ #[derive(Default)]
+ struct FakeStore {
+ secret: Option,
+ inside_call: Option>,
+ fail_put: bool,
+ }
+
+ impl DeepSeekCredentialStore for FakeStore {
+ fn put(&mut self, secret: DeepSeekSecret) -> Result<(), DeepSeekReadinessCode> {
+ self.enter();
+ if self.fail_put {
+ self.exit();
+ return Err(DeepSeekReadinessCode::CredentialStoreUnavailable);
+ }
+ self.secret = Some(secret);
+ self.exit();
+ Ok(())
+ }
+
+ fn read(&self) -> Result {
+ self.enter();
+ let result = self
+ .secret
+ .as_ref()
+ .map(|secret| DeepSeekSecret::new(secret.expose().to_string()).unwrap())
+ .ok_or(DeepSeekReadinessCode::CredentialStoreUnavailable);
+ self.exit();
+ result
+ }
+
+ fn delete(&mut self) -> Result<(), DeepSeekReadinessCode> {
+ self.enter();
+ self.secret = None;
+ self.exit();
+ Ok(())
+ }
+
+ fn contains(&self) -> Result {
+ Ok(self.secret.is_some())
+ }
+ }
+
+ impl FakeStore {
+ fn enter(&self) {
+ if let Some(flag) = &self.inside_call {
+ flag.store(true, Ordering::SeqCst);
+ }
+ }
+
+ fn exit(&self) {
+ if let Some(flag) = &self.inside_call {
+ flag.store(false, Ordering::SeqCst);
+ }
+ }
+ }
+
+ struct ScriptedVerifier {
+ balance: Mutex>>,
+ models: Mutex, DeepSeekTransportFailure>>>,
+ store_inside_call: Option>,
+ }
+
+ impl ScriptedVerifier {
+ fn success() -> Self {
+ Self::new(
+ vec![Ok(true)],
+ vec![Ok(vec![DEEPSEEK_FLASH_MODEL, DEEPSEEK_PRO_MODEL])],
+ )
+ }
+
+ fn new(
+ balance: Vec>,
+ models: Vec, DeepSeekTransportFailure>>,
+ ) -> Self {
+ Self {
+ balance: Mutex::new(balance.into()),
+ models: Mutex::new(models.into()),
+ store_inside_call: None,
+ }
+ }
+ }
+
+ impl DeepSeekReadinessTransport for ScriptedVerifier {
+ fn fetch_user_balance(
+ &self,
+ _api_key: &str,
+ ) -> Result {
+ if let Some(flag) = &self.store_inside_call {
+ assert!(
+ !flag.load(Ordering::SeqCst),
+ "vault lock crossed network I/O"
+ );
+ }
+ self.balance
+ .lock()
+ .unwrap()
+ .pop_front()
+ .unwrap_or(Ok(true))
+ .map(|is_available| DeepSeekUserBalanceResponse {
+ is_available,
+ balance_infos: Vec::new(),
+ })
+ }
+
+ fn fetch_models(
+ &self,
+ _api_key: &str,
+ ) -> Result {
+ if let Some(flag) = &self.store_inside_call {
+ assert!(
+ !flag.load(Ordering::SeqCst),
+ "vault lock crossed network I/O"
+ );
+ }
+ self.models
+ .lock()
+ .unwrap()
+ .pop_front()
+ .unwrap_or_else(|| Ok(vec![DEEPSEEK_FLASH_MODEL, DEEPSEEK_PRO_MODEL]))
+ .map(|models| DeepSeekModelListResponse {
+ data: models
+ .into_iter()
+ .map(|id| DeepSeekModelDescriptor { id: id.to_string() })
+ .collect(),
+ })
+ }
+ }
+
+ fn runtime(store: FakeStore) -> (tempfile::TempDir, DeepSeekCredentialRuntime) {
+ let temp = tempfile::tempdir().unwrap();
+ let state_store = FileDeepSeekCredentialStateStore::new(temp.path()).unwrap();
+ (temp, DeepSeekCredentialRuntime::new(store, state_store))
+ }
+
+ #[test]
+ fn key_input_is_bounded_and_zeroizable() {
+ assert!(matches!(
+ DeepSeekSecret::new(" ".to_string()),
+ Err(DeepSeekReadinessCode::KeyFormatInvalid)
+ ));
+ assert!(matches!(
+ DeepSeekSecret::new("x".repeat(DEEPSEEK_KEY_MAX_BYTES + 1)),
+ Err(DeepSeekReadinessCode::KeyFormatInvalid)
+ ));
+ assert_eq!(
+ DeepSeekSecret::new(" key ".to_string()).unwrap().expose(),
+ "key"
+ );
+ }
+
+ #[test]
+ fn invalid_submitted_key_does_not_create_or_replace_a_stored_key() {
+ let (_temp, runtime) = runtime(FakeStore::default());
+ let projection =
+ runtime.save_and_verify(" ".to_string(), &ScriptedVerifier::success(), |_| None);
+ assert_eq!(projection.source, DeepSeekCredentialSource::Missing);
+ assert!(!projection.configured);
+ assert_eq!(projection.code, DeepSeekReadinessCode::KeyFormatInvalid);
+ assert_eq!(
+ runtime.projection(|_| None).code,
+ DeepSeekReadinessCode::KeyMissing
+ );
+ }
+
+ #[test]
+ fn fake_store_put_read_replace_delete_round_trip() {
+ let mut store = FakeStore::default();
+ store
+ .put(DeepSeekSecret::new("first".to_string()).unwrap())
+ .unwrap();
+ assert_eq!(store.read().unwrap().expose(), "first");
+ store
+ .put(DeepSeekSecret::new("second".to_string()).unwrap())
+ .unwrap();
+ assert_eq!(store.read().unwrap().expose(), "second");
+ store.delete().unwrap();
+ assert!(!store.contains().expect("fake store remains available"));
+ }
+
+ #[test]
+ fn failed_atomic_replacement_preserves_old_secret_and_invalidates_readiness() {
+ let (_temp, runtime) = runtime(FakeStore::default());
+ let ready =
+ runtime.save_and_verify("old-key".to_string(), &ScriptedVerifier::success(), |_| {
+ None
+ });
+ assert!(ready.chat_completion_ready);
+
+ runtime.store.lock().unwrap().fail_put = true;
+ let failed =
+ runtime.save_and_verify("new-key".to_string(), &ScriptedVerifier::success(), |_| {
+ None
+ });
+ assert_eq!(
+ failed.code,
+ DeepSeekReadinessCode::CredentialStoreUnavailable
+ );
+ assert!(!failed.chat_completion_ready);
+
+ let store = runtime.store.lock().unwrap();
+ assert_eq!(store.secret.as_ref().unwrap().expose(), "old-key");
+ drop(store);
+ assert!(matches!(
+ runtime.resolve_ready_key(|_| None),
+ Err(DeepSeekReadinessCode::NotChecked)
+ ));
+ }
+
+ #[test]
+ fn environment_presence_is_not_readiness() {
+ let (_temp, runtime) = runtime(FakeStore::default());
+ let projection = runtime.projection(|_| Some("env-key".to_string()));
+ assert_eq!(projection.source, DeepSeekCredentialSource::Environment);
+ assert_eq!(
+ projection.verification,
+ DeepSeekVerificationState::NotChecked
+ );
+ assert!(!projection.chat_completion_ready);
+ }
+
+ #[test]
+ fn stored_key_precedes_environment_and_requires_receipt() {
+ let (_temp, runtime) = runtime(FakeStore::default());
+ let projection = runtime.save_and_verify(
+ "stored-key".to_string(),
+ &ScriptedVerifier::success(),
+ |_| Some("env-key".to_string()),
+ );
+ assert_eq!(projection.source, DeepSeekCredentialSource::Stored);
+ assert!(projection.chat_completion_ready);
+ assert_eq!(
+ runtime
+ .resolve_ready_key(|_| Some("env-key".to_string()))
+ .unwrap()
+ .expose(),
+ "stored-key"
+ );
+ }
+
+ #[test]
+ fn replacing_key_increments_revision_and_invalidates_old_receipt() {
+ let (temp, runtime) = runtime(FakeStore::default());
+ runtime.save_and_verify("one".to_string(), &ScriptedVerifier::success(), |_| None);
+ let first = FileDeepSeekCredentialStateStore::new(temp.path())
+ .unwrap()
+ .load()
+ .unwrap();
+ runtime.save_and_verify("two".to_string(), &ScriptedVerifier::success(), |_| None);
+ let second = FileDeepSeekCredentialStateStore::new(temp.path())
+ .unwrap()
+ .load()
+ .unwrap();
+ assert_eq!(second.credential_revision, first.credential_revision + 1);
+ assert_eq!(
+ second.receipt.unwrap().credential_revision,
+ second.credential_revision
+ );
+ }
+
+ #[test]
+ fn verification_does_not_hold_vault_call_across_network_io() {
+ let flag = Arc::new(AtomicBool::new(false));
+ let store = FakeStore {
+ secret: None,
+ inside_call: Some(flag.clone()),
+ fail_put: false,
+ };
+ let (_temp, runtime) = runtime(store);
+ let mut verifier = ScriptedVerifier::success();
+ verifier.store_inside_call = Some(flag);
+ let projection = runtime.save_and_verify("key".to_string(), &verifier, |_| None);
+ assert!(projection.chat_completion_ready);
+ }
+
+ #[test]
+ fn unavailable_balance_is_blocked_without_amounts() {
+ let (_temp, runtime) = runtime(FakeStore::default());
+ let verifier = ScriptedVerifier::new(vec![Ok(false)], vec![]);
+ let projection = runtime.save_and_verify("key".to_string(), &verifier, |_| None);
+ assert_eq!(projection.code, DeepSeekReadinessCode::InsufficientBalance);
+ assert_eq!(projection.balance_available, Some(false));
+ assert!(!projection.chat_completion_ready);
+ }
+
+ #[test]
+ fn missing_required_model_is_blocked() {
+ let (_temp, runtime) = runtime(FakeStore::default());
+ let verifier = ScriptedVerifier::new(vec![Ok(true)], vec![Ok(vec![DEEPSEEK_FLASH_MODEL])]);
+ let projection = runtime.save_and_verify("key".to_string(), &verifier, |_| None);
+ assert_eq!(projection.code, DeepSeekReadinessCode::ModelUnavailable);
+ assert_eq!(projection.flash_available, Some(true));
+ assert_eq!(projection.pro_available, Some(false));
+ }
+
+ #[test]
+ fn transport_failures_map_to_frozen_codes() {
+ let cases = [
+ (
+ DeepSeekTransportFailure::HttpStatus(401),
+ false,
+ DeepSeekReadinessCode::AuthenticationFailed,
+ ),
+ (
+ DeepSeekTransportFailure::HttpStatus(402),
+ false,
+ DeepSeekReadinessCode::InsufficientBalance,
+ ),
+ (
+ DeepSeekTransportFailure::HttpStatus(429),
+ false,
+ DeepSeekReadinessCode::RateLimited,
+ ),
+ (
+ DeepSeekTransportFailure::HttpStatus(400),
+ false,
+ DeepSeekReadinessCode::RequestInvalid,
+ ),
+ (
+ DeepSeekTransportFailure::HttpStatus(422),
+ false,
+ DeepSeekReadinessCode::RequestInvalid,
+ ),
+ (
+ DeepSeekTransportFailure::HttpStatus(500),
+ false,
+ DeepSeekReadinessCode::ProviderUnavailable,
+ ),
+ (
+ DeepSeekTransportFailure::HttpStatus(503),
+ false,
+ DeepSeekReadinessCode::ProviderUnavailable,
+ ),
+ (
+ DeepSeekTransportFailure::NetworkUnavailable,
+ false,
+ DeepSeekReadinessCode::NetworkUnavailable,
+ ),
+ (
+ DeepSeekTransportFailure::Timeout,
+ false,
+ DeepSeekReadinessCode::NetworkTimeout,
+ ),
+ (
+ DeepSeekTransportFailure::Protocol,
+ false,
+ DeepSeekReadinessCode::ProviderProtocolError,
+ ),
+ (
+ DeepSeekTransportFailure::HttpStatus(404),
+ true,
+ DeepSeekReadinessCode::ModelUnavailable,
+ ),
+ ];
+ for (failure, during_models, expected) in cases {
+ assert_eq!(
+ readiness_code_from_transport(failure, during_models),
+ expected
+ );
+ }
+ }
+
+ #[test]
+ fn receipt_and_projection_are_secret_path_and_provider_body_free() {
+ let (_temp, runtime) = runtime(FakeStore::default());
+ let projection = runtime.save_and_verify(
+ "marker-secret-value".to_string(),
+ &ScriptedVerifier::success(),
+ |_| None,
+ );
+ let json = serde_json::to_string(&projection).unwrap();
+ assert!(!json.contains("marker-secret-value"));
+ assert!(!json.contains("provider raw body"));
+ assert!(!json.contains("app_data"));
+ assert!(!json.contains("vault"));
+ assert!(!json.contains("total_balance"));
+ assert!(json.contains(DEEPSEEK_FLASH_MODEL));
+ assert!(json.contains(DEEPSEEK_PRO_MODEL));
+ }
+
+ #[test]
+ fn remove_deletes_stored_key_and_receipt_then_reveals_environment() {
+ let (temp, runtime) = runtime(FakeStore::default());
+ runtime.save_and_verify("stored".to_string(), &ScriptedVerifier::success(), |_| None);
+ let projection = runtime.remove(|_| Some("env".to_string()));
+ assert_eq!(projection.source, DeepSeekCredentialSource::Environment);
+ assert_eq!(
+ projection.verification,
+ DeepSeekVerificationState::NotChecked
+ );
+ assert!(!temp.path().join(DEEPSEEK_STATE_FILE).exists());
+ }
+
+ #[test]
+ fn onboarding_transition_table_is_kernel_owned() {
+ let missing_workspace = WorkspaceReadinessProjection {
+ configured: false,
+ workspace_name: None,
+ workspace_root_display: None,
+ root_exists: false,
+ managed_directories_ready: false,
+ writable: None,
+ code: WorkspaceReadinessCode::WorkspaceMissing,
+ retryable: true,
+ message_key: "onboarding.workspace.workspace_missing".to_string(),
+ };
+ let ready_workspace = WorkspaceReadinessProjection {
+ configured: true,
+ workspace_name: Some("Work".to_string()),
+ workspace_root_display: Some("Work".to_string()),
+ root_exists: true,
+ managed_directories_ready: true,
+ writable: Some(true),
+ code: WorkspaceReadinessCode::Ready,
+ retryable: false,
+ message_key: "onboarding.workspace.ready".to_string(),
+ };
+ let missing = build_onboarding_readiness_projection(
+ DeepSeekReadinessProjection::missing(),
+ missing_workspace.clone(),
+ "1.0.2",
+ );
+ assert_eq!(missing.next_step, OnboardingNextStep::DeepseekKey);
+ let verified = DeepSeekReadinessProjection::for_code(
+ DeepSeekCredentialSource::Stored,
+ DeepSeekVerificationState::Verified,
+ DeepSeekReadinessCode::Ready,
+ Some(&DeepSeekVerificationReceipt::verified(1, true, true)),
+ );
+ let needs_workspace =
+ build_onboarding_readiness_projection(verified.clone(), missing_workspace, "1.0.2");
+ assert_eq!(needs_workspace.next_step, OnboardingNextStep::Workspace);
+ let ready = build_onboarding_readiness_projection(verified, ready_workspace, "1.0.2");
+ assert_eq!(ready.overall, OnboardingOverallStatus::Ready);
+ assert_eq!(ready.next_step, OnboardingNextStep::Ready);
+ }
+}
diff --git a/apps/desktop/src-tauri/src/kernel/deepseek_pricing.rs b/apps/desktop/src-tauri/src/kernel/deepseek_pricing.rs
index 0d72b98..f20f16a 100644
--- a/apps/desktop/src-tauri/src/kernel/deepseek_pricing.rs
+++ b/apps/desktop/src-tauri/src/kernel/deepseek_pricing.rs
@@ -90,8 +90,6 @@ impl DeepSeekPricingSettings {
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct DeepSeekPricingState {
- pub app_data_dir: String,
- pub settings_file: String,
pub settings: DeepSeekPricingSettings,
pub pricing_configured: bool,
pub note: String,
@@ -112,11 +110,7 @@ pub fn load_deepseek_pricing_state(
DeepSeekPricingSettings::default()
};
- Ok(deepseek_pricing_state_from_settings(
- app_data_dir,
- &settings_file,
- settings,
- ))
+ Ok(deepseek_pricing_state_from_settings(settings))
}
pub fn save_deepseek_pricing_settings(
@@ -131,11 +125,7 @@ pub fn save_deepseek_pricing_settings(
serde_json::to_string_pretty(&settings).map_err(DeepSeekPricingError::Json)?;
fs::write(&settings_file, settings_json).map_err(DeepSeekPricingError::Write)?;
- Ok(deepseek_pricing_state_from_settings(
- app_data_dir,
- &settings_file,
- settings,
- ))
+ Ok(deepseek_pricing_state_from_settings(settings))
}
pub fn estimate_deepseek_chat_cost_micro_usd(
@@ -171,15 +161,9 @@ pub fn try_estimate_deepseek_chat_cost_micro_usd(
Ok(Some(total.min(u64::MAX as u128) as u64))
}
-fn deepseek_pricing_state_from_settings(
- app_data_dir: &Path,
- settings_file: &Path,
- settings: DeepSeekPricingSettings,
-) -> DeepSeekPricingState {
+fn deepseek_pricing_state_from_settings(settings: DeepSeekPricingSettings) -> DeepSeekPricingState {
let pricing_configured = settings.enabled && settings.has_any_rate();
DeepSeekPricingState {
- app_data_dir: app_data_dir.to_string_lossy().to_string(),
- settings_file: settings_file.to_string_lossy().to_string(),
settings,
pricing_configured,
note: if pricing_configured {
@@ -313,9 +297,10 @@ mod tests {
assert!(!state.settings.enabled);
assert!(!state.pricing_configured);
- assert!(state
- .settings_file
- .ends_with(DEEPSEEK_PRICING_SETTINGS_FILE));
+ let state_json = serde_json::to_string(&state).expect("state serializes");
+ assert!(!state_json.contains(&temp_dir.path().to_string_lossy().to_string()));
+ assert!(!state_json.contains("app_data_dir"));
+ assert!(!state_json.contains("settings_file"));
}
#[test]
@@ -335,6 +320,10 @@ mod tests {
assert!(saved.pricing_configured);
assert_eq!(saved.settings.flash_prompt_usd_per_million_tokens, "0.14");
+ assert!(temp_dir
+ .path()
+ .join(DEEPSEEK_PRICING_SETTINGS_FILE)
+ .is_file());
let loaded = load_deepseek_pricing_state(temp_dir.path()).expect("state reloads");
assert_eq!(loaded, saved);
diff --git a/apps/desktop/src-tauri/src/kernel/local_directory.rs b/apps/desktop/src-tauri/src/kernel/local_directory.rs
index 9af7b40..a130229 100644
--- a/apps/desktop/src-tauri/src/kernel/local_directory.rs
+++ b/apps/desktop/src-tauri/src/kernel/local_directory.rs
@@ -1,8 +1,10 @@
use std::ffi::OsStr;
-use std::fs;
+use std::fs::{self, OpenOptions};
+use std::io::Write;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
+use uuid::Uuid;
pub const LOCAL_DIRECTORY_SETTINGS_FILE: &str = "local-directories.json";
pub const LOCAL_EVIDENCE_DIR_NAME: &str = "evidence";
@@ -48,6 +50,9 @@ pub enum LocalDirectoryError {
#[error("local directory settings are invalid json: {0}")]
Json(serde_json::Error),
+
+ #[error("local workspace managed directories must stay inside the workspace root")]
+ ManagedDirectoryEscape,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
@@ -168,8 +173,23 @@ impl LocalDirectorySettings {
}
fn ensure_directory_structure(&self) -> Result<(), LocalDirectoryError> {
+ let workspace = Path::new(&self.workspace_dir);
+ fs::create_dir_all(workspace).map_err(LocalDirectoryError::Create)?;
+ let canonical_workspace = workspace
+ .canonicalize()
+ .map_err(LocalDirectoryError::Create)?;
for directory in self.standard_directories() {
- fs::create_dir_all(directory).map_err(LocalDirectoryError::Create)?;
+ let directory = PathBuf::from(directory);
+ if !directory.starts_with(workspace) {
+ return Err(LocalDirectoryError::ManagedDirectoryEscape);
+ }
+ fs::create_dir_all(&directory).map_err(LocalDirectoryError::Create)?;
+ let canonical = directory
+ .canonicalize()
+ .map_err(LocalDirectoryError::Create)?;
+ if !canonical.starts_with(&canonical_workspace) {
+ return Err(LocalDirectoryError::ManagedDirectoryEscape);
+ }
}
Ok(())
@@ -246,6 +266,108 @@ pub struct LocalDirectoryReadinessStatus {
pub note: String,
}
+#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum WorkspaceReadinessCode {
+ Ready,
+ WorkspaceMissing,
+ WorkspaceUnavailable,
+ WorkspacePermissionDenied,
+ WorkspaceProbeCleanupFailed,
+ WorkspaceSettingsInvalid,
+}
+
+impl WorkspaceReadinessCode {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::Ready => "ready",
+ Self::WorkspaceMissing => "workspace_missing",
+ Self::WorkspaceUnavailable => "workspace_unavailable",
+ Self::WorkspacePermissionDenied => "workspace_permission_denied",
+ Self::WorkspaceProbeCleanupFailed => "workspace_probe_cleanup_failed",
+ Self::WorkspaceSettingsInvalid => "workspace_settings_invalid",
+ }
+ }
+
+ pub fn retryable(self) -> bool {
+ !matches!(self, Self::Ready | Self::WorkspaceSettingsInvalid)
+ }
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub struct WorkspaceReadinessProjection {
+ pub configured: bool,
+ pub workspace_name: Option,
+ pub workspace_root_display: Option,
+ pub root_exists: bool,
+ pub managed_directories_ready: bool,
+ pub writable: Option,
+ pub code: WorkspaceReadinessCode,
+ pub retryable: bool,
+ pub message_key: String,
+}
+
+impl WorkspaceReadinessProjection {
+ fn for_code(
+ settings: Option<&LocalDirectorySettings>,
+ code: WorkspaceReadinessCode,
+ root_exists: bool,
+ managed_directories_ready: bool,
+ writable: Option,
+ ) -> Self {
+ Self {
+ configured: settings.is_some(),
+ workspace_name: settings.map(|settings| settings.workspace_name.clone()),
+ workspace_root_display: settings
+ .map(|settings| derive_workspace_name(&settings.workspace_dir)),
+ root_exists,
+ managed_directories_ready,
+ writable,
+ code,
+ retryable: code.retryable(),
+ message_key: format!("onboarding.workspace.{}", code.as_str()),
+ }
+ }
+
+ pub fn settings_invalid() -> Self {
+ Self::for_code(
+ None,
+ WorkspaceReadinessCode::WorkspaceSettingsInvalid,
+ false,
+ false,
+ None,
+ )
+ }
+}
+
+pub fn workspace_readiness_projection_from_setup_error(
+ error: &LocalDirectoryError,
+) -> WorkspaceReadinessProjection {
+ let code = match error {
+ LocalDirectoryError::WorkspaceNotDirectory
+ | LocalDirectoryError::EvidenceNotDirectory
+ | LocalDirectoryError::ExportNotDirectory => WorkspaceReadinessCode::WorkspaceUnavailable,
+ LocalDirectoryError::Create(error) | LocalDirectoryError::Migrate(error) => {
+ match error.kind() {
+ std::io::ErrorKind::PermissionDenied => {
+ WorkspaceReadinessCode::WorkspacePermissionDenied
+ }
+ _ => WorkspaceReadinessCode::WorkspaceUnavailable,
+ }
+ }
+ LocalDirectoryError::MissingWorkspace
+ | LocalDirectoryError::MissingEvidence
+ | LocalDirectoryError::MissingExport
+ | LocalDirectoryError::Read(_)
+ | LocalDirectoryError::Write(_)
+ | LocalDirectoryError::Json(_)
+ | LocalDirectoryError::ManagedDirectoryEscape => {
+ WorkspaceReadinessCode::WorkspaceSettingsInvalid
+ }
+ };
+ WorkspaceReadinessProjection::for_code(None, code, false, false, None)
+}
+
impl Default for LocalDirectoryReadinessStatus {
fn default() -> Self {
Self {
@@ -286,6 +408,133 @@ pub fn local_directory_readiness_from_state(
}
}
+pub fn workspace_readiness_projection_from_state(
+ state: &LocalDirectoryState,
+) -> WorkspaceReadinessProjection {
+ let Some(settings) = state.settings.as_ref() else {
+ return WorkspaceReadinessProjection::for_code(
+ None,
+ WorkspaceReadinessCode::WorkspaceMissing,
+ false,
+ false,
+ None,
+ );
+ };
+ let workspace = Path::new(&settings.workspace_dir);
+ if !workspace.is_dir() {
+ return WorkspaceReadinessProjection::for_code(
+ Some(settings),
+ WorkspaceReadinessCode::WorkspaceUnavailable,
+ false,
+ false,
+ None,
+ );
+ }
+ let canonical_workspace = match workspace.canonicalize() {
+ Ok(path) => path,
+ Err(_) => {
+ return WorkspaceReadinessProjection::for_code(
+ Some(settings),
+ WorkspaceReadinessCode::WorkspaceUnavailable,
+ true,
+ false,
+ None,
+ )
+ }
+ };
+ let managed_directories_ready = settings
+ .standard_directories()
+ .into_iter()
+ .all(|directory| {
+ let directory = PathBuf::from(directory);
+ directory.is_dir()
+ && directory
+ .canonicalize()
+ .map(|canonical| canonical.starts_with(&canonical_workspace))
+ .unwrap_or(false)
+ });
+ if !managed_directories_ready {
+ return WorkspaceReadinessProjection::for_code(
+ Some(settings),
+ WorkspaceReadinessCode::WorkspaceSettingsInvalid,
+ true,
+ false,
+ None,
+ );
+ }
+ match workspace_write_probe(workspace) {
+ Ok(()) => WorkspaceReadinessProjection::for_code(
+ Some(settings),
+ WorkspaceReadinessCode::Ready,
+ true,
+ true,
+ Some(true),
+ ),
+ Err(WorkspaceReadinessCode::WorkspaceProbeCleanupFailed) => {
+ WorkspaceReadinessProjection::for_code(
+ Some(settings),
+ WorkspaceReadinessCode::WorkspaceProbeCleanupFailed,
+ true,
+ true,
+ Some(false),
+ )
+ }
+ Err(_) => WorkspaceReadinessProjection::for_code(
+ Some(settings),
+ WorkspaceReadinessCode::WorkspacePermissionDenied,
+ true,
+ true,
+ Some(false),
+ ),
+ }
+}
+
+fn workspace_write_probe(root: &Path) -> Result<(), WorkspaceReadinessCode> {
+ workspace_write_probe_with_cleanup(root, |path| fs::remove_file(path))
+}
+
+fn workspace_write_probe_with_cleanup(
+ root: &Path,
+ cleanup: impl FnOnce(&Path) -> std::io::Result<()>,
+) -> Result<(), WorkspaceReadinessCode> {
+ let canonical_root = root
+ .canonicalize()
+ .map_err(|_| WorkspaceReadinessCode::WorkspaceUnavailable)?;
+ let probe = root.join(format!(".ds-agent-readiness-{}.tmp", Uuid::new_v4()));
+ if probe.parent() != Some(root) || !probe.starts_with(root) {
+ return Err(WorkspaceReadinessCode::WorkspaceSettingsInvalid);
+ }
+ let mut file = OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .open(&probe)
+ .map_err(|_| WorkspaceReadinessCode::WorkspacePermissionDenied)?;
+ let write_result = file
+ .write_all(b"ds-agent-readiness-v1")
+ .and_then(|_| file.sync_all());
+ drop(file);
+ if write_result.is_err() {
+ let _ = fs::remove_file(&probe);
+ return Err(WorkspaceReadinessCode::WorkspacePermissionDenied);
+ }
+ let canonical_probe = match probe.canonicalize() {
+ Ok(path) if path.starts_with(&canonical_root) => path,
+ _ => {
+ let _ = fs::remove_file(&probe);
+ return Err(WorkspaceReadinessCode::WorkspaceSettingsInvalid);
+ }
+ };
+ if cleanup(&canonical_probe).is_err() {
+ let _ = fs::remove_file(&canonical_probe);
+ return Err(WorkspaceReadinessCode::WorkspaceProbeCleanupFailed);
+ }
+ if canonical_probe.exists() {
+ let _ = fs::remove_file(&canonical_probe);
+ return Err(WorkspaceReadinessCode::WorkspaceProbeCleanupFailed);
+ }
+ Ok(())
+}
+
pub fn load_local_directory_state(
app_data_dir: impl AsRef,
) -> Result {
@@ -298,7 +547,8 @@ pub fn load_local_directory_state(
serde_json::from_str(&settings_json).map_err(LocalDirectoryError::Json)?;
settings.normalize_derived_directories()?;
if settings.workspace_exists() {
- settings.ensure_directory_structure()?;
+ // Existing compatible settings are repaired in place without moving data.
+ let _ = settings.ensure_directory_structure();
}
Some(settings)
} else {
@@ -494,10 +744,12 @@ mod tests {
use std::fs;
use super::{
- load_local_directory_state, save_local_directory_settings, LocalDirectorySettings,
- LOCAL_DIRECTORY_SETTINGS_FILE, LOCAL_EVIDENCE_DIR_NAME, LOCAL_EXPORT_DIR_NAME,
- LOCAL_LOGS_DIR_NAME, LOCAL_MEMORY_DIR_NAME, LOCAL_REPORTS_DIR_NAME, LOCAL_RUNS_DIR_NAME,
- LOCAL_SOURCES_DIR_NAME, LOCAL_WORK_PACKAGES_DIR_NAME,
+ load_local_directory_state, save_local_directory_settings,
+ workspace_readiness_projection_from_setup_error, workspace_readiness_projection_from_state,
+ workspace_write_probe_with_cleanup, LocalDirectoryError, LocalDirectorySettings,
+ WorkspaceReadinessCode, LOCAL_DIRECTORY_SETTINGS_FILE, LOCAL_EVIDENCE_DIR_NAME,
+ LOCAL_EXPORT_DIR_NAME, LOCAL_LOGS_DIR_NAME, LOCAL_MEMORY_DIR_NAME, LOCAL_REPORTS_DIR_NAME,
+ LOCAL_RUNS_DIR_NAME, LOCAL_SOURCES_DIR_NAME, LOCAL_WORK_PACKAGES_DIR_NAME,
};
#[test]
@@ -579,6 +831,36 @@ mod tests {
assert!(temp_dir.path().join(LOCAL_DIRECTORY_SETTINGS_FILE).exists());
}
+ #[test]
+ fn first_run_workspace_setup_does_not_migrate_unrelated_existing_data() {
+ let temp_dir = tempfile::tempdir().expect("temp dir");
+ let app_data_dir = temp_dir.path().join("fresh-app-data");
+ let unrelated_workspace = temp_dir.path().join("old-workspace");
+ let unrelated_file = unrelated_workspace
+ .join(LOCAL_EVIDENCE_DIR_NAME)
+ .join("keep.txt");
+ fs::create_dir_all(unrelated_file.parent().expect("unrelated parent"))
+ .expect("create unrelated workspace");
+ fs::write(&unrelated_file, "keep in place").expect("write unrelated data");
+ let new_workspace = temp_dir.path().join("new-workspace");
+
+ save_local_directory_settings(
+ &app_data_dir,
+ LocalDirectorySettings::from_workspace_dir(new_workspace.to_string_lossy().to_string())
+ .expect("new settings"),
+ )
+ .expect("first-run settings save");
+
+ assert_eq!(
+ fs::read_to_string(&unrelated_file).expect("unrelated data remains"),
+ "keep in place"
+ );
+ assert!(!new_workspace
+ .join(LOCAL_EVIDENCE_DIR_NAME)
+ .join("keep.txt")
+ .exists());
+ }
+
#[test]
fn saving_local_directory_settings_preserves_workspace_name() {
let temp_dir = tempfile::tempdir().expect("temp dir");
@@ -733,6 +1015,22 @@ mod tests {
assert!(workspace_dir.join(LOCAL_EVIDENCE_DIR_NAME).is_dir());
}
+ #[test]
+ fn corrupt_workspace_settings_are_not_rewritten_or_deleted() {
+ let temp_dir = tempfile::tempdir().expect("temp dir");
+ let settings_file = temp_dir.path().join(LOCAL_DIRECTORY_SETTINGS_FILE);
+ fs::write(&settings_file, "{not valid json").expect("write corrupt settings");
+
+ let error = load_local_directory_state(temp_dir.path())
+ .expect_err("corrupt settings must be reported");
+
+ assert!(matches!(error, super::LocalDirectoryError::Json(_)));
+ assert_eq!(
+ fs::read_to_string(settings_file).expect("corrupt settings preserved"),
+ "{not valid json"
+ );
+ }
+
#[test]
fn local_directory_settings_reject_blank_required_paths() {
let error = LocalDirectorySettings::from_workspace_dir(" ".to_string())
@@ -740,4 +1038,117 @@ mod tests {
assert_eq!(error.to_string(), "workspace directory is required");
}
+
+ #[test]
+ fn workspace_readiness_uses_bounded_probe_and_redacted_display() {
+ let temp_dir = tempfile::tempdir().expect("temp dir");
+ let workspace_dir = temp_dir.path().join("Hotel Workspace");
+ let state = save_local_directory_settings(
+ temp_dir.path().join("app-data"),
+ LocalDirectorySettings::from_workspace_dir_and_name(
+ workspace_dir.to_string_lossy().to_string(),
+ "Hotel Ops".to_string(),
+ )
+ .expect("settings"),
+ )
+ .expect("save");
+
+ let projection = workspace_readiness_projection_from_state(&state);
+ let json = serde_json::to_string(&projection).expect("projection json");
+
+ assert_eq!(projection.code, WorkspaceReadinessCode::Ready);
+ assert_eq!(projection.writable, Some(true));
+ assert_eq!(projection.workspace_name.as_deref(), Some("Hotel Ops"));
+ assert_eq!(
+ projection.workspace_root_display.as_deref(),
+ Some("Hotel Workspace")
+ );
+ assert!(!json.contains(&temp_dir.path().to_string_lossy().to_string()));
+ assert!(fs::read_dir(&workspace_dir)
+ .expect("workspace entries")
+ .all(|entry| !entry
+ .expect("entry")
+ .file_name()
+ .to_string_lossy()
+ .starts_with(".ds-agent-readiness-")));
+ }
+
+ #[test]
+ fn workspace_managed_directories_cannot_escape_root() {
+ let temp_dir = tempfile::tempdir().expect("temp dir");
+ let workspace_dir = temp_dir.path().join("workspace");
+ let outside = temp_dir.path().join("outside");
+ let settings = LocalDirectorySettings::from_optional_dirs(
+ workspace_dir.to_string_lossy().to_string(),
+ None,
+ Some(outside.to_string_lossy().to_string()),
+ None,
+ )
+ .expect("settings parse");
+
+ let error = save_local_directory_settings(temp_dir.path().join("app-data"), settings)
+ .expect_err("escape must fail");
+
+ assert!(matches!(
+ error,
+ super::LocalDirectoryError::ManagedDirectoryEscape
+ ));
+ assert!(!outside.exists());
+ }
+
+ #[test]
+ fn workspace_probe_cleanup_failure_blocks_readiness_without_residue() {
+ let temp_dir = tempfile::tempdir().expect("temp dir");
+ let error = workspace_write_probe_with_cleanup(temp_dir.path(), |_| {
+ Err(std::io::Error::new(
+ std::io::ErrorKind::PermissionDenied,
+ "injected cleanup failure",
+ ))
+ })
+ .expect_err("cleanup failure must block");
+
+ assert_eq!(error, WorkspaceReadinessCode::WorkspaceProbeCleanupFailed);
+ assert!(fs::read_dir(temp_dir.path())
+ .expect("root entries")
+ .next()
+ .is_none());
+ }
+
+ #[test]
+ fn workspace_probe_maps_create_failure_to_permission_denied() {
+ let temp_dir = tempfile::tempdir().expect("temp dir");
+ let file_root = temp_dir.path().join("not-a-directory");
+ fs::write(&file_root, "occupied").expect("write file root");
+
+ let error = workspace_write_probe_with_cleanup(&file_root, |_| Ok(()))
+ .expect_err("probe create must fail");
+
+ assert_eq!(error, WorkspaceReadinessCode::WorkspacePermissionDenied);
+ assert_eq!(
+ fs::read_to_string(file_root).expect("file root remains"),
+ "occupied"
+ );
+ }
+
+ #[test]
+ fn workspace_setup_errors_map_to_stable_secret_free_codes() {
+ let permission =
+ workspace_readiness_projection_from_setup_error(&LocalDirectoryError::Create(
+ std::io::Error::new(std::io::ErrorKind::PermissionDenied, "private path detail"),
+ ));
+ let invalid = workspace_readiness_projection_from_setup_error(
+ &LocalDirectoryError::ManagedDirectoryEscape,
+ );
+ let permission_json = serde_json::to_string(&permission).expect("projection serializes");
+
+ assert_eq!(
+ permission.code,
+ WorkspaceReadinessCode::WorkspacePermissionDenied
+ );
+ assert_eq!(
+ invalid.code,
+ WorkspaceReadinessCode::WorkspaceSettingsInvalid
+ );
+ assert!(!permission_json.contains("private path detail"));
+ }
}
diff --git a/apps/desktop/src-tauri/src/kernel/mod.rs b/apps/desktop/src-tauri/src/kernel/mod.rs
index da6a8f7..157631c 100644
--- a/apps/desktop/src-tauri/src/kernel/mod.rs
+++ b/apps/desktop/src-tauri/src/kernel/mod.rs
@@ -15,6 +15,7 @@ pub mod computer_use_runtime;
pub mod computer_use_session;
pub mod connectors;
pub mod deepseek;
+pub mod deepseek_credential;
pub mod deepseek_pricing;
pub mod event_store;
pub mod expert_team;
diff --git a/apps/desktop/src-tauri/src/kernel/work_package.rs b/apps/desktop/src-tauri/src/kernel/work_package.rs
index 68e6c7e..185a3f0 100644
--- a/apps/desktop/src-tauri/src/kernel/work_package.rs
+++ b/apps/desktop/src-tauri/src/kernel/work_package.rs
@@ -1,7 +1,7 @@
use crate::kernel::computer_use::{
computer_use_backend_status_for_strategy_with_codex_bridge_config, ComputerUseBackendStatus,
};
-use crate::kernel::deepseek::{deepseek_credential_status_from_env, DeepSeekCredentialStatus};
+use crate::kernel::deepseek_credential::DeepSeekReadinessProjection;
use crate::kernel::local_directory::LocalDirectoryReadinessStatus;
use crate::kernel::models::{FoundationState, MemoryCandidate, TaskRecord};
use crate::kernel::network_search::{
@@ -45,7 +45,11 @@ pub struct WorkPackage {
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct WorkPackageToolReadiness {
- pub deepseek: DeepSeekCredentialStatus,
+ #[serde(
+ default,
+ deserialize_with = "deserialize_work_package_deepseek_readiness"
+ )]
+ pub deepseek: DeepSeekReadinessProjection,
pub network_search: NetworkSearchRouteStatus,
pub computer_use: ComputerUseBackendStatus,
#[serde(default)]
@@ -54,9 +58,33 @@ pub struct WorkPackageToolReadiness {
pub tool_strategy: ModelDrivenToolStrategy,
}
+fn deserialize_work_package_deepseek_readiness<'de, D>(
+ deserializer: D,
+) -> Result
+where
+ D: serde::Deserializer<'de>,
+{
+ let value = ::deserialize(deserializer)?;
+ if value.get("source").is_none()
+ || value.get("verification").is_none()
+ || value.get("code").is_none()
+ {
+ return Ok(DeepSeekReadinessProjection::default());
+ }
+ let projection = serde_json::from_value::(value)
+ .map_err(serde::de::Error::custom)?;
+ let coherent_ready = projection.verification
+ == crate::kernel::deepseek_credential::DeepSeekVerificationState::Verified
+ && projection.code == crate::kernel::deepseek_credential::DeepSeekReadinessCode::Ready;
+ if projection.chat_completion_ready != coherent_ready {
+ return Ok(DeepSeekReadinessProjection::default());
+ }
+ Ok(projection)
+}
+
impl Default for WorkPackageToolReadiness {
fn default() -> Self {
- let deepseek = deepseek_credential_status_from_env(|_| None);
+ let deepseek = DeepSeekReadinessProjection::default();
let foundation_state = FoundationState::default();
let tool_strategy = model_driven_tool_strategy_with_native_network_search_bridge(
foundation_state.large_model_provider,
@@ -343,8 +371,10 @@ mod tests {
export_work_package, export_work_package_with_tool_readiness, parse_work_package_json,
WorkPackageError, WorkPackageToolReadiness, REDACTED_SOURCE_MACHINE_EVIDENCE_HANDLE,
};
- use crate::kernel::deepseek::{
- deepseek_credential_status_from_env, DeepSeekCredentialStatus, DEEPSEEK_API_KEY_ENV,
+ use crate::kernel::deepseek::{DEEPSEEK_FLASH_MODEL, DEEPSEEK_PRO_MODEL};
+ use crate::kernel::deepseek_credential::{
+ DeepSeekCredentialSource, DeepSeekReadinessCode, DeepSeekReadinessProjection,
+ DeepSeekVerificationState,
};
use crate::kernel::local_directory::{
local_directory_readiness_from_state, LocalDirectorySettings, LocalDirectoryState,
@@ -608,13 +638,21 @@ mod tests {
#[test]
fn tool_readiness_export_package_stays_secret_safe() {
- let deepseek_status = deepseek_credential_status_from_env(|name| {
- if name == DEEPSEEK_API_KEY_ENV {
- Some("test-secret-token".to_string())
- } else {
- None
- }
- });
+ let deepseek_status = DeepSeekReadinessProjection {
+ source: DeepSeekCredentialSource::Environment,
+ configured: true,
+ verification: DeepSeekVerificationState::NotChecked,
+ code: DeepSeekReadinessCode::NotChecked,
+ chat_completion_ready: false,
+ balance_available: None,
+ flash_model: DEEPSEEK_FLASH_MODEL.to_string(),
+ pro_model: DEEPSEEK_PRO_MODEL.to_string(),
+ flash_available: None,
+ pro_available: None,
+ retryable: false,
+ last_verified_at: None,
+ message_key: "onboarding.deepseek.not_checked".to_string(),
+ };
let package = export_work_package_with_tool_readiness(
FoundationState::default(),
Vec::new(),
@@ -628,8 +666,9 @@ mod tests {
let package_json = serde_json::to_string(&package).expect("package serializes");
- assert!(package.tool_readiness.deepseek.api_key_configured);
- assert!(package_json.contains(DEEPSEEK_API_KEY_ENV));
+ assert!(package.tool_readiness.deepseek.configured);
+ assert!(package_json.contains("\"source\":\"environment\""));
+ assert!(!package_json.contains("DEEPSEEK_API_KEY"));
assert!(package_json.contains("pending_user_confirmation"));
assert!(package_json.contains("\"local_directories\""));
if cfg!(target_os = "macos") {
@@ -707,22 +746,44 @@ mod tests {
assert_eq!(
package.tool_readiness.deepseek,
- DeepSeekCredentialStatus {
- base_url: "https://api.deepseek.com".to_string(),
- chat_completions_url: "https://api.deepseek.com/chat/completions".to_string(),
- api_key_env_var: DEEPSEEK_API_KEY_ENV.to_string(),
- api_key_configured: false,
- chat_completion_ready: false,
- flash_model: "deepseek-v4-flash".to_string(),
- pro_model: "deepseek-v4-pro".to_string(),
- readiness_note:
- "set DEEPSEEK_API_KEY in the local process environment to enable Chat Completions requests"
- .to_string(),
- }
+ DeepSeekReadinessProjection::default()
);
assert_eq!(package.tool_readiness, WorkPackageToolReadiness::default());
}
+ #[test]
+ fn tool_readiness_legacy_deepseek_shape_cannot_import_false_ready_authority() {
+ let mut tool_readiness = serde_json::to_value(WorkPackageToolReadiness::default())
+ .expect("readiness serializes");
+ tool_readiness["deepseek"] = serde_json::json!({
+ "base_url": "https://api.deepseek.com",
+ "chat_completions_url": "https://api.deepseek.com/chat/completions",
+ "api_key_env_var": "DEEPSEEK_API_KEY",
+ "api_key_configured": true,
+ "chat_completion_ready": true,
+ "flash_model": "deepseek-v4-flash",
+ "pro_model": "deepseek-v4-pro"
+ });
+ let package_json = serde_json::json!({
+ "version": "deepseek-agent-os.work-package.v1",
+ "exported_at": chrono::Utc::now(),
+ "foundation_state": FoundationState::default(),
+ "tool_readiness": tool_readiness,
+ "task_records": [],
+ "memory_candidates": [],
+ "operations_briefing_runs": []
+ })
+ .to_string();
+
+ let package = parse_work_package_json(&package_json).expect("legacy package parses");
+
+ assert_eq!(
+ package.tool_readiness.deepseek,
+ DeepSeekReadinessProjection::default()
+ );
+ assert!(!package.tool_readiness.deepseek.chat_completion_ready);
+ }
+
#[test]
fn operations_export_legacy_package_json_defaults_briefing_runs() {
let package_json = serde_json::json!({
diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs
index 1ce4ab0..2d144bb 100644
--- a/apps/desktop/src-tauri/src/main.rs
+++ b/apps/desktop/src-tauri/src/main.rs
@@ -1,5 +1,6 @@
#![cfg_attr(all(windows, not(test)), windows_subsystem = "windows")]
+mod app_paths;
mod app_update_commands;
mod artifact_commands;
mod automation_commands;
@@ -10,6 +11,7 @@ mod kernel;
mod lifecycle_commands;
mod workspace_undo_commands;
+use app_paths::resolve_app_data_dir;
use app_update_commands::{check_app_update, download_app_update, install_app_update};
use artifact_commands::{
get_artifact_visual_preview, list_artifact_deliveries, spawn_artifact_recovery_worker,
@@ -33,15 +35,15 @@ use commands::{
export_operations_briefing_report, export_work_package, finish_agent_run_record,
get_agent_soul_profile, get_computer_control_unlock_status, get_computer_use_backend_status,
get_computer_use_backend_status_for_model, get_deepseek_chat_cache_state,
- get_deepseek_credential_status, get_deepseek_pricing_state, get_deepseek_user_balance,
- get_foundation_state, get_local_directory_state, get_model_driven_tool_strategy,
- get_network_search_route_status, get_network_search_route_status_for_model,
- import_work_package, ingest_evidence_folder, install_local_skill_manifest,
- install_local_skill_zip_package, install_remote_skill_zip_package,
- install_skill_from_repository_url, link_memory_candidate_to_conflicts, link_memory_records,
- list_agent_context_receipts, list_agent_run_records, list_agent_tool_contracts,
- list_agent_tool_invocations, list_capability_access_records, list_capability_catalog,
- list_capability_invocations, list_deepseek_chat_telemetry, list_durable_computer_use_sessions,
+ get_deepseek_pricing_state, get_foundation_state, get_local_directory_state,
+ get_model_driven_tool_strategy, get_network_search_route_status,
+ get_network_search_route_status_for_model, get_onboarding_readiness, import_work_package,
+ ingest_evidence_folder, install_local_skill_manifest, install_local_skill_zip_package,
+ install_remote_skill_zip_package, install_skill_from_repository_url,
+ link_memory_candidate_to_conflicts, link_memory_records, list_agent_context_receipts,
+ list_agent_run_records, list_agent_tool_contracts, list_agent_tool_invocations,
+ list_capability_access_records, list_capability_catalog, list_capability_invocations,
+ list_deepseek_chat_telemetry, list_durable_computer_use_sessions,
list_durable_computer_use_steps, list_memory_candidate_records,
list_memory_maintenance_reviews, list_memory_records, list_operations_briefing_runs,
list_pending_capability_access_records, list_permission_audit_entries,
@@ -54,19 +56,20 @@ use commands::{
queue_expert_team_retries, queue_parent_agent_synthesis, read_drive_boundary,
read_email_boundary, read_local_file, record_agent_run_artifact_record,
record_agent_run_step_record, record_memory_maintenance_review_action, record_permission_audit,
- record_selected_memory_feedback, reobserve_durable_computer_use_session,
- replace_memory_candidate_conflicts, request_agent_run_cancel_record, request_capability_access,
- reset_skill_trust, resolve_capability_access_request, resolve_memory_candidate,
- resume_agent_chat_action, run_agent_chat, run_durable_computer_use_step,
- run_memory_background_maintenance, run_next_queued_agent_chat_worker, run_operations_briefing,
- run_skill_update_sweep, run_terminal_read, run_terminal_write, save_agent_soul_profile,
+ record_selected_memory_feedback, remove_deepseek_api_key,
+ reobserve_durable_computer_use_session, replace_memory_candidate_conflicts,
+ request_agent_run_cancel_record, request_capability_access, reset_skill_trust,
+ resolve_capability_access_request, resolve_memory_candidate, resume_agent_chat_action,
+ run_agent_chat, run_durable_computer_use_step, run_memory_background_maintenance,
+ run_next_queued_agent_chat_worker, run_operations_briefing, run_skill_update_sweep,
+ run_terminal_read, run_terminal_write, save_agent_soul_profile, save_deepseek_api_key,
save_deepseek_pricing_settings, save_local_directory_settings, search_memory_records,
search_network_boundary, seed_operations_briefing_evidence_templates, send_email_boundary,
set_skill_enabled, stage_agent_attachments, start_agent_run_record,
start_durable_computer_use_session, submit_browser_boundary,
take_over_durable_computer_use_step, uninstall_skill, unlock_computer_control,
- update_memory_candidate_conflict, update_memory_record, verify_skill_source,
- write_drive_boundary, write_file_boundary, AppState,
+ update_memory_candidate_conflict, update_memory_record, verify_deepseek_api_key,
+ verify_skill_source, write_drive_boundary, write_file_boundary, AppState,
};
use connected_work_commands::{
approve_and_run_connected_work_review, list_connected_work_reviews,
@@ -261,7 +264,7 @@ fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.setup(|app| {
- let app_data_dir = app.path().app_data_dir()?;
+ let app_data_dir = resolve_app_data_dir(app.handle()).map_err(std::io::Error::other)?;
std::fs::create_dir_all(&app_data_dir)?;
#[cfg(windows)]
app.manage(
@@ -313,7 +316,10 @@ fn main() {
list_agent_tool_contracts,
list_agent_tool_invocations,
execute_agent_tool,
- get_deepseek_credential_status,
+ get_onboarding_readiness,
+ save_deepseek_api_key,
+ verify_deepseek_api_key,
+ remove_deepseek_api_key,
get_network_search_route_status,
get_computer_use_backend_status,
get_network_search_route_status_for_model,
@@ -373,7 +379,6 @@ fn main() {
record_agent_run_step_record,
record_agent_run_artifact_record,
finish_agent_run_record,
- get_deepseek_user_balance,
clear_deepseek_chat_cache,
list_deepseek_chat_telemetry,
list_agent_context_receipts,
diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json
index 9b4dfa8..68ab1ef 100644
--- a/apps/desktop/src-tauri/tauri.conf.json
+++ b/apps/desktop/src-tauri/tauri.conf.json
@@ -2,7 +2,7 @@
"$schema": "https://schema.tauri.app/config/2",
"productName": "DS Agent",
"mainBinaryName": "ds-agent",
- "version": "1.0.2",
+ "version": "1.1.0",
"identifier": "ai.deepseek-agent-os.desktop",
"build": {
"beforeDevCommand": "npx pnpm@9.15.9 dev",
diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx
index b45cf27..2608fee 100644
--- a/apps/desktop/src/App.tsx
+++ b/apps/desktop/src/App.tsx
@@ -74,7 +74,6 @@ import {
} from "./agentAttachments";
import type { AgentAttachment } from "./agentAttachments";
import {
- deepSeekApiKeyCandidates,
settingsPanelItems,
shouldExposePluginsSidebarEntry,
} from "./settingsPanel";
@@ -112,13 +111,11 @@ import type {
DeepSeekChatCacheState,
DeepSeekChatTelemetry,
DeepSeekPricingState,
- DeepSeekUserBalanceResponse,
ComputerUseBackendStatus,
- DeepSeekCredentialStatus,
FoundationState,
LargeModelProvider,
Language,
- LocalDirectoryState,
+ OnboardingReadinessProjection,
MemoryBackgroundMaintenanceSummary,
MemoryCandidate,
MemoryCandidateRecord,
@@ -192,16 +189,43 @@ const fallbackState: FoundationState = {
},
};
-const fallbackDeepSeekCredentialStatus: DeepSeekCredentialStatus = {
- base_url: "https://api.deepseek.com",
- chat_completions_url: "https://api.deepseek.com/chat/completions",
- api_key_env_var: "DEEPSEEK_API_KEY",
- api_key_configured: false,
- chat_completion_ready: false,
- flash_model: "deepseek-v4-flash",
- pro_model: "deepseek-v4-pro",
- readiness_note:
- "set DEEPSEEK_API_KEY in the local process environment to enable Chat Completions requests",
+const fallbackOnboardingReadiness: OnboardingReadinessProjection = {
+ schema_version: 1,
+ overall: "setup_required",
+ next_step: "deepseek_key",
+ deepseek: {
+ source: "missing",
+ configured: false,
+ verification: "not_checked",
+ code: "key_missing",
+ chat_completion_ready: false,
+ balance_available: null,
+ flash_model: "deepseek-v4-flash",
+ pro_model: "deepseek-v4-pro",
+ flash_available: null,
+ pro_available: null,
+ retryable: false,
+ last_verified_at: null,
+ message_key: "onboarding.deepseek.key_missing",
+ },
+ workspace: {
+ configured: false,
+ workspace_name: null,
+ workspace_root_display: null,
+ root_exists: false,
+ managed_directories_ready: false,
+ writable: null,
+ code: "workspace_missing",
+ retryable: true,
+ message_key: "onboarding.workspace.workspace_missing",
+ },
+ version: {
+ current_version: "1.1.0",
+ status: "current",
+ blocking: false,
+ message_key: "onboarding.version.current",
+ },
+ checked_at: null,
};
const fallbackDeepSeekChatCacheState: DeepSeekChatCacheState = {
@@ -299,13 +323,6 @@ const fallbackModelDrivenToolStrategy: ModelDrivenToolStrategy = {
"Selected model route needs a separate source-linked web-search option before search can run.",
};
-const fallbackLocalDirectoryState: LocalDirectoryState = {
- app_data_dir: "",
- settings_file: "",
- settings: null,
- needs_setup: true,
-};
-
const fallbackAgentSoulProfileState: AgentSoulProfileState = {
exists: false,
content: "",
@@ -324,8 +341,6 @@ const fallbackAppUpdateStatus: AppUpdateStatus = {
};
const fallbackDeepSeekPricingState: DeepSeekPricingState = {
- app_data_dir: "",
- settings_file: "",
pricing_configured: false,
note: "DeepSeek cost estimates are disabled until a local pricing table is configured",
settings: {
@@ -417,7 +432,7 @@ type MemoryEditDraft = {
type WorkflowStepState = "done" | "current" | "waiting" | "needs_action" | "blocked";
type WorkflowStatusTone = "ready" | "running" | "needs_action" | "done" | "blocked";
-type AgentChatSetupPrompt = "deepseek_key" | "workspace" | "network_search";
+type AgentChatSetupPrompt = "deepseek_key" | "workspace" | "doctor" | "network_search";
type QueuedAgentPrompt = {
prompt: string;
displayPrompt: string;
@@ -861,8 +876,11 @@ function capabilityFamilyIcon(family: CapabilityFamily) {
export function App() {
const [state, setState] = useState(fallbackState);
- const [deepSeekCredentialStatus, setDeepSeekCredentialStatus] =
- useState(fallbackDeepSeekCredentialStatus);
+ const [onboardingReadiness, setOnboardingReadiness] =
+ useState(fallbackOnboardingReadiness);
+ const deepSeekCredentialStatus = onboardingReadiness.deepseek;
+ const workspaceReadiness = onboardingReadiness.workspace;
+ const localDirectoryNeedsSetup = workspaceReadiness.code !== "ready";
const [deepSeekChatCacheState, setDeepSeekChatCacheState] =
useState(fallbackDeepSeekChatCacheState);
const [deepSeekTelemetry, setDeepSeekTelemetry] = useState([]);
@@ -876,8 +894,6 @@ export function App() {
const [computerUseSteps, setComputerUseSteps] = useState([]);
const [modelToolStrategy, setModelToolStrategy] =
useState(fallbackModelDrivenToolStrategy);
- const [localDirectoryState, setLocalDirectoryState] =
- useState(fallbackLocalDirectoryState);
const [soulProfileState, setSoulProfileState] =
useState(fallbackAgentSoulProfileState);
const [appUpdateStatus, setAppUpdateStatus] =
@@ -1014,10 +1030,7 @@ export function App() {
const [agentChatNotice, setAgentChatNotice] = useState("");
const [agentSetupPrompt, setAgentSetupPrompt] = useState(null);
const [pendingAgentPrompt, setPendingAgentPrompt] = useState("");
- const [sessionDeepSeekApiKey, setSessionDeepSeekApiKey] = useState("");
- const [fallbackDeepSeekApiKey, setFallbackDeepSeekApiKey] = useState("");
const [deepSeekApiKeyDraft, setDeepSeekApiKeyDraft] = useState("");
- const [deepSeekBalance, setDeepSeekBalance] = useState(null);
const [exportedPackageJson, setExportedPackageJson] = useState("");
const [importPackageJson, setImportPackageJson] = useState("");
const [importPreview, setImportPreview] = useState(null);
@@ -1081,7 +1094,8 @@ export function App() {
const [deepSeekCacheError, setDeepSeekCacheError] = useState("");
const [deepSeekPricingNotice, setDeepSeekPricingNotice] = useState("");
const [deepSeekPricingError, setDeepSeekPricingError] = useState("");
- const [deepSeekBalanceError, setDeepSeekBalanceError] = useState("");
+ const [deepSeekReadinessNotice, setDeepSeekReadinessNotice] = useState("");
+ const [deepSeekReadinessError, setDeepSeekReadinessError] = useState("");
const [packagePending, setPackagePending] = useState(false);
const [memoryPending, setMemoryPending] = useState(false);
const [memoryCandidatePending, setMemoryCandidatePending] = useState(false);
@@ -1112,7 +1126,7 @@ export function App() {
const [appUpdateInstallPending, setAppUpdateInstallPending] = useState(false);
const [deepSeekCachePending, setDeepSeekCachePending] = useState(false);
const [deepSeekPricingPending, setDeepSeekPricingPending] = useState(false);
- const [deepSeekBalancePending, setDeepSeekBalancePending] = useState(false);
+ const [deepSeekReadinessPending, setDeepSeekReadinessPending] = useState(false);
const [capabilityPending, setCapabilityPending] = useState(null);
const [resolutionPending, setResolutionPending] = useState(null);
const agentMessagesRef = useRef(agentMessages);
@@ -1389,42 +1403,29 @@ export function App() {
unlockHint: "The local unlock above is also required before execution.",
screenshotHint: "Capture a fresh screenshot in Computer Use tools before re-observing.",
};
- const deepSeekBalanceStatus = deepSeekBalance
- ? deepSeekBalance.is_available
- ? copy.settingsPanel.balanceAvailable
- : copy.settingsPanel.balanceUnavailable
- : copy.settingsPanel.balanceNotQueried;
- const deepSeekBalanceDetails = deepSeekBalance
- ? deepSeekBalance.balance_infos.length > 0
- ? deepSeekBalance.balance_infos
- .map(
- (info) =>
- `${info.currency} ${info.total_balance} (${info.topped_up_balance} + ${info.granted_balance})`,
- )
- .join(" / ")
- : copy.settingsPanel.balanceEmpty
- : "";
- const primaryDeepSeekApiKeyPlaceholder = sessionDeepSeekApiKey
- ? copy.settingsPanel.apiKeyPlaceholder
- : deepSeekCredentialStatus.api_key_configured
+ const deepSeekReadinessMessage =
+ copy.onboarding.deepseekMessages[deepSeekCredentialStatus.code];
+ const workspaceReadinessMessage =
+ copy.onboarding.workspaceMessages[workspaceReadiness.code];
+ const primaryDeepSeekApiKeyPlaceholder = deepSeekCredentialStatus.configured
? copy.settingsPanel.apiKeyConfiguredPlaceholder
: copy.settingsPanel.apiKeyPlaceholder;
const primaryDeepSeekApiKeyReady = deepSeekCredentialStatus.chat_completion_ready;
- const fallbackDeepSeekApiKeyReady = false;
-
- const hydrateLocalDirectoryInputs = (directoryState: LocalDirectoryState) => {
- if (!directoryState.settings) {
- return;
+ const canRetryDeepSeekReadiness =
+ deepSeekCredentialStatus.retryable ||
+ deepSeekCredentialStatus.code === "not_checked" ||
+ deepSeekCredentialStatus.code === "ready";
+ const modelAvailabilityMessage = (available: boolean | null) =>
+ available === null
+ ? copy.onboarding.notChecked
+ : available
+ ? copy.onboarding.available
+ : copy.onboarding.unavailable;
+
+ const hydrateWorkspaceInputs = (readiness: OnboardingReadinessProjection) => {
+ if (readiness.workspace.workspace_name) {
+ setSetupWorkspaceName(readiness.workspace.workspace_name);
}
-
- const { workspace_dir, workspace_name, evidence_dir, export_dir } =
- directoryState.settings;
- setSetupWorkspaceName(workspace_name);
- setSetupWorkspaceDir(workspace_dir);
- setBriefingFolderPath((current) => current || evidence_dir);
- setFolderPath((current) => current || evidence_dir);
- setDriveLocation((current) => current || workspace_dir);
- setDriveWriteLocation((current) => current || export_dir);
};
const hydrateDeepSeekPricingInputs = (pricingState: DeepSeekPricingState) => {
@@ -1479,11 +1480,10 @@ export function App() {
useEffect(() => {
if (!hasDesktopRuntime()) {
setState(fallbackState);
- setDeepSeekCredentialStatus(fallbackDeepSeekCredentialStatus);
+ setOnboardingReadiness(fallbackOnboardingReadiness);
setDeepSeekChatCacheState(fallbackDeepSeekChatCacheState);
setDeepSeekTelemetry([]);
setComputerControlUnlockStatus(fallbackComputerControlUnlockStatus);
- setLocalDirectoryState(fallbackLocalDirectoryState);
applySoulProfileState(fallbackAgentSoulProfileState);
setAppUpdateStatus(fallbackAppUpdateStatus);
setDeepSeekPricingState(fallbackDeepSeekPricingState);
@@ -1500,9 +1500,18 @@ export function App() {
}
})
.catch(() => setState(fallbackState));
- void invoke("get_deepseek_credential_status")
- .then(setDeepSeekCredentialStatus)
- .catch(() => setDeepSeekCredentialStatus(fallbackDeepSeekCredentialStatus));
+ void invoke("get_onboarding_readiness")
+ .then((readiness) => {
+ setOnboardingReadiness(readiness);
+ hydrateWorkspaceInputs(readiness);
+ if (readiness.overall !== "ready") {
+ setAgentSetupPrompt(readiness.next_step === "ready" ? "doctor" : readiness.next_step);
+ }
+ })
+ .catch(() => {
+ setOnboardingReadiness(fallbackOnboardingReadiness);
+ setDeepSeekReadinessError(copy.onboarding.loadFailed);
+ });
void invoke("get_deepseek_chat_cache_state")
.then(setDeepSeekChatCacheState)
.catch(() => setDeepSeekChatCacheState(fallbackDeepSeekChatCacheState));
@@ -1516,15 +1525,6 @@ export function App() {
setComputerUseSessions([]);
setComputerUseSteps([]);
});
- void invoke("get_local_directory_state")
- .then((directoryState) => {
- setLocalDirectoryState(directoryState);
- hydrateLocalDirectoryInputs(directoryState);
- })
- .catch(() => {
- setLocalDirectoryState(fallbackLocalDirectoryState);
- setSetupError(copy.localSetup.loadFailed);
- });
void invoke("get_agent_soul_profile")
.then(applySoulProfileState)
.catch(() => {
@@ -2304,7 +2304,7 @@ export function App() {
const persistLocalDirectorySetup = async (
options: { workspaceDir?: string; workspaceName?: string } = {},
- ): Promise => {
+ ): Promise => {
const workspaceDir = options.workspaceDir ?? setupWorkspaceDir;
const workspaceName = options.workspaceName ?? setupWorkspaceName;
setSetupPending(true);
@@ -2312,20 +2312,22 @@ export function App() {
setSetupNotice("");
try {
- const directoryState = await invoke(
+ const readiness = await invoke(
"save_local_directory_settings",
{
workspaceDir,
workspaceName,
},
);
- setLocalDirectoryState(directoryState);
- hydrateLocalDirectoryInputs(directoryState);
- setSetupNotice(copy.localSetup.saved);
- return true;
- } catch (error) {
- setSetupError(String(error) || copy.localSetup.failed);
- return false;
+ setOnboardingReadiness(readiness);
+ hydrateWorkspaceInputs(readiness);
+ if (readiness.workspace.code === "ready") {
+ setSetupNotice(copy.localSetup.saved);
+ }
+ return readiness;
+ } catch {
+ setSetupError(copy.localSetup.failed);
+ return null;
} finally {
setSetupPending(false);
}
@@ -2385,32 +2387,67 @@ export function App() {
}
};
- const queryDeepSeekBalance = async () => {
- setDeepSeekBalancePending(true);
- setDeepSeekBalanceError("");
+ const saveDeepSeekKey = async (apiKey: string): Promise => {
+ setDeepSeekReadinessPending(true);
+ setDeepSeekReadinessError("");
+ setDeepSeekReadinessNotice("");
+ try {
+ const readiness = await invoke("save_deepseek_api_key", {
+ apiKey,
+ });
+ setOnboardingReadiness(readiness);
+ if (
+ readiness.deepseek.source === "stored" &&
+ readiness.deepseek.code !== "key_format_invalid" &&
+ readiness.deepseek.code !== "credential_store_unavailable"
+ ) {
+ setDeepSeekReadinessNotice(copy.onboarding.keySaved);
+ }
+ return readiness;
+ } catch {
+ setDeepSeekReadinessError(copy.onboarding.commandFailed);
+ return null;
+ } finally {
+ setDeepSeekApiKeyDraft("");
+ setDeepSeekReadinessPending(false);
+ }
+ };
+ const retryDeepSeekReadiness = async (): Promise => {
+ setDeepSeekReadinessPending(true);
+ setDeepSeekReadinessError("");
+ setDeepSeekReadinessNotice("");
try {
- const apiKeyCandidates = deepSeekApiKeyCandidates(
- sessionDeepSeekApiKey,
- fallbackDeepSeekApiKey,
+ const readiness = await invoke(
+ "verify_deepseek_api_key",
);
- if (!deepSeekCredentialStatus.chat_completion_ready && apiKeyCandidates.length === 0) {
- setDeepSeekBalanceError(copy.chatWorkbench.deepSeekKeyRequired);
- return;
- }
+ setOnboardingReadiness(readiness);
+ setDeepSeekReadinessNotice(copy.onboarding.checkFinished);
+ return readiness;
+ } catch {
+ setDeepSeekReadinessError(copy.onboarding.commandFailed);
+ return null;
+ } finally {
+ setDeepSeekApiKeyDraft("");
+ setDeepSeekReadinessPending(false);
+ }
+ };
- const balance = await invoke(
- "get_deepseek_user_balance",
- {
- apiKeyOverride: apiKeyCandidates[0] ?? null,
- fallbackApiKeyOverride: apiKeyCandidates[1] ?? null,
- },
+ const removeDeepSeekKey = async () => {
+ setDeepSeekReadinessPending(true);
+ setDeepSeekReadinessError("");
+ setDeepSeekReadinessNotice("");
+ try {
+ const readiness = await invoke(
+ "remove_deepseek_api_key",
);
- setDeepSeekBalance(balance);
- } catch (error) {
- setDeepSeekBalanceError(String(error) || copy.settingsPanel.balanceFailed);
+ setOnboardingReadiness(readiness);
+ setDeepSeekReadinessNotice(copy.onboarding.keyRemoved);
+ } catch {
+ setDeepSeekReadinessError(copy.onboarding.commandFailed);
} finally {
- setDeepSeekBalancePending(false);
+ setDeepSeekApiKeyDraft("");
+ setDeepSeekReadinessPending(false);
}
};
@@ -2438,8 +2475,8 @@ export function App() {
setSetupWorkspaceDir(previousWorkspaceDir);
}
}
- } catch (error) {
- setSetupError(String(error) || copy.localSetup.chooseFailed);
+ } catch {
+ setSetupError(copy.localSetup.chooseFailed);
}
};
@@ -2570,7 +2607,7 @@ export function App() {
};
useEffect(() => {
- if (!hasDesktopRuntime() || localDirectoryState.needs_setup) {
+ if (!hasDesktopRuntime() || localDirectoryNeedsSetup) {
return;
}
let cancelled = false;
@@ -2589,7 +2626,7 @@ export function App() {
cancelled = true;
window.clearInterval(timer);
};
- }, [localDirectoryState.needs_setup]);
+ }, [localDirectoryNeedsSetup]);
useEffect(() => {
if (!hasDesktopRuntime() || (!agentChatPending && !hasOpenAgentRuns)) {
@@ -2607,24 +2644,19 @@ export function App() {
}, [agentChatPending, hasOpenAgentRuns]);
useEffect(() => {
- if (!hasDesktopRuntime() || localDirectoryState.needs_setup) {
+ if (!hasDesktopRuntime() || localDirectoryNeedsSetup) {
return;
}
let cancelled = false;
const runNextDurableAgentTask = async () => {
- const apiKeyCandidates = deepSeekApiKeyCandidates(
- sessionDeepSeekApiKey,
- fallbackDeepSeekApiKey,
- );
if (cancelled || !shouldRunDurableAgentWorker({
desktopRuntime: hasDesktopRuntime(),
- setupNeeded: localDirectoryState.needs_setup,
+ setupNeeded: localDirectoryNeedsSetup,
workerBusy: backgroundAgentWorkerBusyRef.current,
chatPending: agentChatPendingRef.current,
queuedLocalCount: queuedAgentPromptRef.current.length,
- credentialReady:
- deepSeekCredentialStatus.chat_completion_ready || apiKeyCandidates.length > 0,
+ credentialReady: deepSeekCredentialStatus.chat_completion_ready,
})) {
return;
}
@@ -2676,8 +2708,6 @@ export function App() {
thinkingLevel: state.thinking_level,
accessMode: state.access_mode,
networkSearchSourceModel: state.network_search_source_model || null,
- apiKeyOverride: apiKeyCandidates[0] ?? null,
- fallbackApiKeyOverride: apiKeyCandidates[1] ?? null,
}),
),
);
@@ -2733,8 +2763,6 @@ export function App() {
thinkingLevel: state.thinking_level,
accessMode: state.access_mode,
networkSearchSourceModel: state.network_search_source_model || null,
- apiKeyOverride: apiKeyCandidates[0] ?? null,
- fallbackApiKeyOverride: apiKeyCandidates[1] ?? null,
},
);
if (!workerResult || cancelled) {
@@ -2862,9 +2890,7 @@ export function App() {
};
}, [
deepSeekCredentialStatus.chat_completion_ready,
- fallbackDeepSeekApiKey,
- localDirectoryState.needs_setup,
- sessionDeepSeekApiKey,
+ localDirectoryNeedsSetup,
state.access_mode,
state.large_model_provider,
state.model_route,
@@ -2885,16 +2911,8 @@ export function App() {
};
const runMemoryBackgroundMaintenance = async () => {
- const apiKeyCandidates = deepSeekApiKeyCandidates(
- sessionDeepSeekApiKey,
- fallbackDeepSeekApiKey,
- );
const summary = await invoke(
"run_memory_background_maintenance",
- {
- apiKeyOverride: apiKeyCandidates[0] ?? null,
- fallbackApiKeyOverride: apiKeyCandidates[1] ?? null,
- },
);
await Promise.all([refreshMemoryCandidateRecords(), refreshMemoryMaintenanceReviews()]);
return summary;
@@ -3812,8 +3830,7 @@ export function App() {
const runOperationsBriefingWorkflow = async (event: FormEvent) => {
event.preventDefault();
- const trimmedPath =
- briefingFolderPath.trim() || localDirectoryState.settings?.evidence_dir.trim() || "";
+ const trimmedPath = briefingFolderPath.trim();
if (!trimmedPath) {
setBriefingError(copy.operationsBriefing.failed);
return;
@@ -4047,7 +4064,6 @@ export function App() {
const sendAgentPrompt = async (
promptValue: string,
options: {
- apiKeyOverride?: string;
skipWorkspaceSetup?: boolean;
skipNetworkSearchSetup?: boolean;
isGuidanceContinuation?: boolean;
@@ -4100,20 +4116,14 @@ export function App() {
}
const runToken = (agentChatRunTokenRef.current += 1);
- const apiKeyCandidates = deepSeekApiKeyCandidates(
- options.apiKeyOverride ?? sessionDeepSeekApiKey,
- fallbackDeepSeekApiKey,
- );
- if (!deepSeekCredentialStatus.chat_completion_ready && apiKeyCandidates.length === 0) {
+ if (onboardingReadiness.overall !== "ready") {
setPendingAgentPrompt(prompt);
setDeepSeekApiKeyDraft("");
- setAgentSetupPrompt("deepseek_key");
- return;
- }
-
- if (localDirectoryState.needs_setup && !options.skipWorkspaceSetup) {
- setPendingAgentPrompt(prompt);
- setAgentSetupPrompt("workspace");
+ setAgentSetupPrompt(
+ onboardingReadiness.next_step === "ready"
+ ? "doctor"
+ : onboardingReadiness.next_step,
+ );
return;
}
@@ -4253,8 +4263,6 @@ export function App() {
thinkingLevel: state.thinking_level,
accessMode: state.access_mode,
networkSearchSourceModel: state.network_search_source_model || null,
- apiKeyOverride: apiKeyCandidates[0] ?? null,
- fallbackApiKeyOverride: apiKeyCandidates[1] ?? null,
});
void invoke("record_agent_run_step_record", {
runId: agentRunId,
@@ -4277,8 +4285,6 @@ export function App() {
thinkingLevel: state.thinking_level,
accessMode: state.access_mode,
networkSearchSourceModel: state.network_search_source_model || null,
- apiKeyOverride: apiKeyCandidates[0] ?? null,
- fallbackApiKeyOverride: apiKeyCandidates[1] ?? null,
},
);
if (!workerResult) {
@@ -4547,25 +4553,34 @@ export function App() {
const continueAgentAfterDeepSeekKey = async (event: FormEvent) => {
event.preventDefault();
- const trimmedKey = deepSeekApiKeyDraft.trim();
- if (!trimmedKey) {
+ let submittedKey = deepSeekApiKeyDraft.trim();
+ if (!submittedKey) {
setAgentChatError(copy.chatWorkbench.deepSeekKeyRequired);
return;
}
- setSessionDeepSeekApiKey(trimmedKey);
+ const readiness = await saveDeepSeekKey(submittedKey);
+ submittedKey = "";
+ if (!readiness) {
+ return;
+ }
+ if (readiness.overall !== "ready") {
+ setAgentSetupPrompt(readiness.next_step === "ready" ? "doctor" : readiness.next_step);
+ return;
+ }
+ const prompt = pendingAgentPrompt;
setAgentSetupPrompt(null);
- await sendAgentPrompt(pendingAgentPrompt, {
- apiKeyOverride: trimmedKey,
- attachments: agentAttachments,
- });
setPendingAgentPrompt("");
- setDeepSeekApiKeyDraft("");
+ await sendAgentPrompt(prompt, { attachments: agentAttachments });
};
const continueAgentAfterWorkspaceSetup = async (event: FormEvent) => {
event.preventDefault();
- const saved = await persistLocalDirectorySetup();
- if (!saved) {
+ const readiness = await persistLocalDirectorySetup();
+ if (!readiness) {
+ return;
+ }
+ if (readiness.overall !== "ready") {
+ setAgentSetupPrompt(readiness.next_step === "ready" ? "doctor" : readiness.next_step);
return;
}
const prompt = pendingAgentPrompt;
@@ -4577,6 +4592,38 @@ export function App() {
});
};
+ const continueAgentAfterDoctor = async (event: FormEvent) => {
+ event.preventDefault();
+ let readiness: OnboardingReadinessProjection | null = null;
+ if (deepSeekCredentialStatus.chat_completion_ready) {
+ setDeepSeekReadinessPending(true);
+ setDeepSeekReadinessError("");
+ try {
+ readiness = await invoke(
+ "get_onboarding_readiness",
+ );
+ setOnboardingReadiness(readiness);
+ } catch {
+ setDeepSeekReadinessError(copy.onboarding.commandFailed);
+ } finally {
+ setDeepSeekReadinessPending(false);
+ }
+ } else {
+ readiness = await retryDeepSeekReadiness();
+ }
+ if (!readiness) {
+ return;
+ }
+ if (readiness.overall !== "ready") {
+ setAgentSetupPrompt(readiness.next_step === "ready" ? "doctor" : readiness.next_step);
+ return;
+ }
+ const prompt = pendingAgentPrompt;
+ setAgentSetupPrompt(null);
+ setPendingAgentPrompt("");
+ await sendAgentPrompt(prompt, { attachments: agentAttachments });
+ };
+
const continueAgentAfterNetworkSearchSetup = async (event: FormEvent) => {
event.preventDefault();
if (modelToolStrategy.network_search_source_model_required && !state.network_search_source_model) {
@@ -4919,7 +4966,7 @@ export function App() {
? "done"
: briefingPending
? "current"
- : localDirectoryState.needs_setup
+ : localDirectoryNeedsSetup
? "blocked"
: "waiting",
},
@@ -5181,15 +5228,25 @@ export function App() {
aria-label={copy.settingsPanel.title}
data-settings-count={settingsPanelItemCount}
>
-
- {copy.settingsPanel.deepSeekApiKey}
+