Compare commits

...

2 Commits

Author SHA1 Message Date
lanrtop
663a723b3b feat(dev-variables): 修改时显式查看原值,并提示 agent 先查炼境测试账户
All checks were successful
Push & PR Check / check (push) Successful in 1m5s
Push & PR Check / check (pull_request) Successful in 1m9s
- V12:新增人用 Tauri command reveal_project_dev_variable(不进 MCP),
  点「修改」预填原账号与密码,密码默认遮罩可切换显示
- V13:lian-jing skill 新增第 8 步——需要测试账号先 list/read,
  没有则登记并请用户在 UI 填写;禁止对话索要明文;base-onboarding 升至 1.5.0
- 契约同步 project-context.yaml;materialize 提案顺延为 V14–V16

Feature-Confirmed: true
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:22:54 +09:00
lanrtop
f74fe11145 fix(ui): 确认框在 Tauri 下被绕过——改用 await plugin-dialog confirm
tauri-plugin-dialog 把 window.confirm 替换为 async 函数,同步判断拿到恒为真的
Promise,撤销测试账户、删除 Gitea 实例/解绑仓库、删除仓库、准备工具链 5 处
破坏性或改机器状态的操作均未弹框即执行(UI 终验发现)。统一 await
@tauri-apps/plugin-dialog 的 confirm,并加源码扫描回归测试防复发。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:48:54 +09:00
14 changed files with 219 additions and 33 deletions

View File

@ -186,3 +186,26 @@ progress: 91
- acceptance: "agent_verify: [真实 scoped MCP/cargo test] 合成项目登记→同一本体填写→再次查询已配置→读取→撤销后读取失败,并确认目录没有新增 .env 或明文配置;cargo test、pnpm typecheck、git diff --check exit 0。human_verify: 真实桌面 UI 与真实 Agent 客户端跨会话便利性另报未验证,不冒充 V7 完成。" - acceptance: "agent_verify: [真实 scoped MCP/cargo test] 合成项目登记→同一本体填写→再次查询已配置→读取→撤销后读取失败,并确认目录没有新增 .env 或明文配置;cargo test、pnpm typecheck、git diff --check exit 0。human_verify: 真实桌面 UI 与真实 Agent 客户端跨会话便利性另报未验证,不冒充 V7 完成。"
- human_verify: pending(真实桌面 UI 和真实 Agent 客户端跨会话复用尚未终验;V7 保持 blocked) - human_verify: pending(真实桌面 UI 和真实 Agent 客户端跨会话复用尚未终验;V7 保持 blocked)
- evidence: 真实本机 scoped MCP HTTP 合成链路逐步断言登记、待填写、受保护存储填写、复查已配置、项目 bearer 读取、撤销后拒绝;数据库重开复用测试通过。`cargo test --locked` 227 passed/4 ignored,`mise exec -- pnpm typecheck` 与 `git diff --check` exit 0;未新增 `.env` 或明文配置文件。 - evidence: 真实本机 scoped MCP HTTP 合成链路逐步断言登记、待填写、受保护存储填写、复查已配置、项目 bearer 读取、撤销后拒绝;数据库重开复用测试通过。`cargo test --locked` 227 passed/4 ignored,`mise exec -- pnpm typecheck` 与 `git diff --check` exit 0;未新增 `.env` 或明文配置文件。
## 任务拆解:UI 终验反馈与 agent 可发现性(2026-09-30)
> 来源:用户对 V7 的 human_verify 否决理由——「修改时空白,应像修改配置一样呈现旧值」「撤销无确认框」(后者为 bug,已由 `f74fe11` 修复并加回归测试);以及 agent 侧可发现性缺口:接入包 skill 未提示「需要测试账号时先查炼境」,agent 会继续向人索要。
### ✅ V12 — 修改时显式查看原值(人用投影)
- status: done
- complexity: M
- files: src-tauri/src/commands/dev_variables.rs, src-tauri/src/lib.rs, src/lib/commands.ts, src/components/dashboard/ProjectDevVariablesPanel.tsx, docs/ai-context/project-context.yaml
- depends: V10
- acceptance: "agent_verify: [cargo test] reveal(已配置测试账户) → 账号与密码原值;reveal(标量) → 原值;未配置 → 错误;MCP tools/list 不含任何 reveal 工具。human_verify: 点「修改」→ 账号预填可见、密码预填但默认遮罩,点「显示」可见;保存后生效。"
- verification: "2026-09-30:cargo test reveal_returns_original_value_by_kind_only_for_humans 覆盖测试账户/标量取回、未配置与撤销后报错、MCP tools/list 不含 reveal;全量 cargo test --locked 228 passed / 5 ignored,pnpm typecheck exit 0,vitest 8 passed。"
- human_verify: pending(第二次 UI 终验)
- decision: 值仅经本机 Tauri command 在人显式点「修改」时取回,沿用 API Key R2 的显式 reveal 模式;不进 MCP,agent 取值仍只走已认证的 read_project_test_account。密码默认遮罩防截图/旁观泄露。
### ✅ V13 — 接入包 skill 提示 agent 先查炼境测试账户
- status: done
- complexity: S
- files: src-tauri/resources/onboarding/.agents/skills/lian-jing/SKILL.md, src-tauri/src/commands/onboarding.rs
- depends: V9
- acceptance: "agent_verify: [cargo test onboarding] base-onboarding 版本升级后旧版本项目报告 version_mismatch、新模板含测试账户指引。human_verify: 合并并重建后对 Enterprise apply_onboarding_pack,agent 需要登录时先查炼境而非向人索要。"
- verification: "2026-09-30:SKILL.md 新增第 8 步(先 list,已配置单项读取,否则登记并请用户在 UI 填写;禁止对话索要明文、禁止登记生产、禁止写入 Git 跟踪文件、认证被拒如实报告);BASE_ONBOARDING_PACK_VERSION 与模板 capability-packs.json 同步升至 1.5.0(仅升常量时 2 个 onboarding 测试失败,已同步修正);cargo test --locked 全绿。"
- human_verify: pending(分发到 Enterprise 后观察 agent 行为)

View File

@ -31,7 +31,7 @@ contracts:
paths: paths:
- "src-tauri/src/commands/**/*.rs" - "src-tauri/src/commands/**/*.rs"
- "src/lib/commands.ts" - "src/lib/commands.ts"
doc: "新增 command 时前后端必须同步。Gitea/API Key 普通 DTO 不含原始凭证;API Key 仅本机 UI 显式 reveal,get_project_resource_status 仅返回非秘密状态;项目开发需求列表 DTO 仅含名称、类型、目标系统、环境、用途、configured 与时间。本机 UI 对 test_account 一次提交账号/密码到同一受保护引用,允许修改或撤销但无常态 reveal;旧 scalar 保持兼容。旧 SQLite 明文列按 write→verify→事务切换引用并清除的顺序兼容迁移,失败需可恢复,不能降级明文。Agent readiness 必须保留 configured/reachable/handshake/tools_ready/reload_required 分层;Windows autostart 默认关闭,只允许正式安装版,debug/target 构建必须拒绝启用。" doc: "新增 command 时前后端必须同步。Gitea/API Key 普通 DTO 不含原始凭证;API Key 仅本机 UI 显式 reveal,get_project_resource_status 仅返回非秘密状态;项目开发需求列表 DTO 仅含名称、类型、目标系统、环境、用途、configured 与时间。本机 UI 对 test_account 一次提交账号/密码到同一受保护引用,允许修改或撤销;列表常态不含值,仅人显式点「修改」时经 reveal_project_dev_variable 取回原值(密码默认遮罩),该 command 不进 MCP;旧 scalar 保持兼容。旧 SQLite 明文列按 write→verify→事务切换引用并清除的顺序兼容迁移,失败需可恢复,不能降级明文。Agent readiness 必须保留 configured/reachable/handshake/tools_ready/reload_required 分层;Windows autostart 默认关闭,只允许正式安装版,debug/target 构建必须拒绝启用。"
architecture_docs: architecture_docs:
- "AGENTS.md" - "AGENTS.md"

View File

@ -36,13 +36,13 @@
- 返回值、错误文本、日志均不含账号或密码。 - 返回值、错误文本、日志均不含账号或密码。
- MCP 投影走 scoped 路由,属写工具。客户端审批策略(参见 V7 blocker)按正常审批处理,不标注为只读来绕过。人的投影:`ProjectDevVariablesPanel` 可选加「写入工作区」按钮,属于 human_verify 范围,非 v1 必需。 - MCP 投影走 scoped 路由,属写工具。客户端审批策略(参见 V7 blocker)按正常审批处理,不标注为只读来绕过。人的投影:`ProjectDevVariablesPanel` 可选加「写入工作区」按钮,属于 human_verify 范围,非 v1 必需。
## 任务卡草案(合入 dev-variables 模块后编号接续 V11) ## 任务卡草案(合入 dev-variables 模块后编号接续 V13;V12/V13 已被 UI 终验反馈占用)
- **V12 落地本体**(M)— files: `src-tauri/src/commands/dev_variables.rs`, `src/lib/commands.ts`;depends: project-dev-variables 合入 master - **V14 落地本体**(M)— files: `src-tauri/src/commands/dev_variables.rs`, `src/lib/commands.ts`;depends: project-dev-variables 合入 master
- acceptance: `agent_verify: [cargo test] 临时 git 仓库 + 模拟受保护存储:目标被忽略 → 仅两变量行写入、其他行保留、二次调用字节不变;撤销后调用 → 两行被删;目标未被忽略 / 越出工作区 / worktree 属另一仓库 / 账户未填写 → 拒绝且文件字节不变;返回与错误序列化不含账号密码。` - acceptance: `agent_verify: [cargo test] 临时 git 仓库 + 模拟受保护存储:目标被忽略 → 仅两变量行写入、其他行保留、二次调用字节不变;撤销后调用 → 两行被删;目标未被忽略 / 越出工作区 / worktree 属另一仓库 / 账户未填写 → 拒绝且文件字节不变;返回与错误序列化不含账号密码。`
- **V13 MCP 投影**(M)— files: `src-tauri/src/mcp_tools.rs`;depends: V12 - **V15 MCP 投影**(M)— files: `src-tauri/src/mcp_tools.rs`;depends: V14
- acceptance: `agent_verify: [cargo test] tools/list 含 materialize_project_test_account,schema 要求 project_id;跨项目参数被 scope 拒绝;无认证调用可写入但响应只含变量名;仍无任何批量导出或 get_secret 工具。` - acceptance: `agent_verify: [cargo test] tools/list 含 materialize_project_test_account,schema 要求 project_id;跨项目参数被 scope 拒绝;无认证调用可写入但响应只含变量名;仍无任何批量导出或 get_secret 工具。`
- **V14 Enterprise worktree 试点**(M)— files: `.blueprint/modules/project-dev-variables.md`, `docs/ai-context/project-context.yaml`, `docs/ai-context/agent-button-system.md`;depends: V13 - **V16 Enterprise worktree 试点**(M)— files: `.blueprint/modules/project-dev-variables.md`, `docs/ai-context/project-context.yaml`, `docs/ai-context/agent-button-system.md`;depends: V15
- acceptance: `agent_verify: [CLI] Enterprise 新建隔离 worktree(无 .env.agent)→ [MCP] materialize_project_test_account(enterprise, <账户名>, ".env.agent", "AGENT_USERNAME", "AGENT_PASSWORD", worktree) → [CLI] 起本地 API 后 pnpm --silent --filter @enterprise-system/api agent:token exit 0 且 stdout 非空;git status 显示 .env.agent 未被跟踪;会话记录不含账号值。` - acceptance: `agent_verify: [CLI] Enterprise 新建隔离 worktree(无 .env.agent)→ [MCP] materialize_project_test_account(enterprise, <账户名>, ".env.agent", "AGENT_USERNAME", "AGENT_PASSWORD", worktree) → [CLI] 起本地 API 后 pnpm --silent --filter @enterprise-system/api agent:token exit 0 且 stdout 非空;git status 显示 .env.agent 未被跟踪;会话记录不含账号值。`
- `human_verify: 在炼境登记一次后,agent 在新 worktree 中无需向人索要账号。` - `human_verify: 在炼境登记一次后,agent 在新 worktree 中无需向人索要账号。`
- 度量:人工传递账号次数(目标 0)、新 worktree 就绪到首次 agent:token 成功的耗时。未达收益按「管家退出」规则撤销。 - 度量:人工传递账号次数(目标 0)、新 worktree 就绪到首次 agent:token 成功的耗时。未达收益按「管家退出」规则撤销。

View File

@ -16,6 +16,7 @@ description: 读取炼境项目健康状态,并使用炼境 MCP 的项目管
5. 交付、Gitea、CI、蓝图等平台状态优先通过 MCP 查询,不凭静态文档猜测。 5. 交付、Gitea、CI、蓝图等平台状态优先通过 MCP 查询,不凭静态文档猜测。
6. 合并 PR 前调用 `get_delivery_status(project_id)`,向用户展示 PR 编号、完整 head SHA、CI 与 review。只有当前用户明确确认该 PR 与 SHA 后,才调用 `merge_pull_request(project_id, pr_number, expected_head_sha, confirmed=true)`;合并后再次读取交付状态。不得自行推断确认。 6. 合并 PR 前调用 `get_delivery_status(project_id)`,向用户展示 PR 编号、完整 head SHA、CI 与 review。只有当前用户明确确认该 PR 与 SHA 后,才调用 `merge_pull_request(project_id, pr_number, expected_head_sha, confirmed=true)`;合并后再次读取交付状态。不得自行推断确认。
7. 生成 CI workflow 时先调用 `plan_cicd_workflow(project_id, workflow_type)`,向用户展示目标文件、变更类型、`content_digest`、工具链来源与所需 secret 名称;只有用户明确确认这份摘要后,才以同一 digest 调用 `apply_cicd_workflow(..., confirmed=true)`。该流程只写项目 workflow,不配置 secret、不触发 CI 或部署;不得通过 MCP 传服务器地址、命令、路径或 secret 值,部署类配置先由用户在炼境 UI 保存。 7. 生成 CI workflow 时先调用 `plan_cicd_workflow(project_id, workflow_type)`,向用户展示目标文件、变更类型、`content_digest`、工具链来源与所需 secret 名称;只有用户明确确认这份摘要后,才以同一 digest 调用 `apply_cicd_workflow(..., confirmed=true)`。该流程只写项目 workflow,不配置 secret、不触发 CI 或部署;不得通过 MCP 传服务器地址、命令、路径或 secret 值,部署类配置先由用户在炼境 UI 保存。
8. 需要测试账号或开发变量(登录自验、沙箱 key 等)时,先调用 `list_project_dev_variables` 查找:已配置则用 `read_project_test_account` / `read_project_dev_variable` 单项读取;没有则用 `register_project_test_account` 登记名称、目标系统、非生产环境与用途,并请用户在炼境项目 UI「开发变量」中填写。不得在对话中向用户索要明文密码,不得登记生产账号,不得把读到的值写入受 Git 跟踪的文件。读取因缺少项目认证被拒时(当前仅 Codex 经 helper 验证可读),如实报告并改用项目原有配置方式,不得绕过认证。
如果 MCP 未出现,按以下顺序降级,不猜测状态: 如果 MCP 未出现,按以下顺序降级,不猜测状态:

View File

@ -3,7 +3,7 @@
"packs": [ "packs": [
{ {
"pack_id": "base-onboarding", "pack_id": "base-onboarding",
"version": "1.4.0", "version": "1.5.0",
"supported_clients": ["codex", "claude"], "supported_clients": ["codex", "claude"],
"project_artifacts": [ "project_artifacts": [
"AGENTS.md", "AGENTS.md",

View File

@ -24,6 +24,14 @@ pub struct TestAccountValue {
pub password: String, pub password: String,
} }
/// 人在 UI 显式「修改」时取回的原值;仅本机 Tauri 投影使用,MCP 不公开。
#[derive(Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum DevVariableReveal {
Scalar { value: String },
TestAccount { username: String, password: String },
}
const SCALAR_KIND: &str = "scalar"; const SCALAR_KIND: &str = "scalar";
const TEST_ACCOUNT_KIND: &str = "test_account"; const TEST_ACCOUNT_KIND: &str = "test_account";
@ -238,6 +246,35 @@ pub fn read_test_account(
Ok(account) Ok(account)
} }
/// 按登记类型取回原值,复用 agent 读取同一本体,只是投影给人。
pub fn reveal(
conn: &Connection,
store: &impl CredentialStore,
project_id: &str,
name: &str,
) -> Result<DevVariableReveal, String> {
require_project(conn, project_id)?;
let name = normalized_name(name)?;
let kind: String = conn
.query_row(
"SELECT kind FROM project_dev_variables WHERE project_id=?1 AND name=?2",
params![project_id, name],
|row| row.get(0),
)
.map_err(|_| "开发变量未登记".to_string())?;
if kind == TEST_ACCOUNT_KIND {
let account = read_test_account(conn, store, project_id, &name)?;
Ok(DevVariableReveal::TestAccount {
username: account.username,
password: account.password,
})
} else {
Ok(DevVariableReveal::Scalar {
value: read(conn, store, project_id, &name)?,
})
}
}
fn read_value( fn read_value(
conn: &Connection, conn: &Connection,
store: &impl CredentialStore, store: &impl CredentialStore,
@ -444,6 +481,23 @@ pub fn save_project_test_account(
) )
} }
/// 仅供本机 UI 在用户显式点「修改」时调用;MCP 不公开此 command。
#[tauri::command]
pub fn reveal_project_dev_variable(
project_id: String,
name: String,
) -> Result<DevVariableReveal, String> {
let conn = crate::db::pool()
.get()
.map_err(|_| "数据库不可用".to_string())?;
reveal(
&conn,
&crate::credential_store::WindowsCredentialStore,
&project_id,
&name,
)
}
#[tauri::command] #[tauri::command]
pub fn revoke_project_dev_variable(project_id: String, name: String) -> Result<(), String> { pub fn revoke_project_dev_variable(project_id: String, name: String) -> Result<(), String> {
let conn = crate::db::pool() let conn = crate::db::pool()
@ -507,6 +561,34 @@ mod tests {
assert!(read(&conn, &store, "p1", "E2E_PASSWORD").is_err()); assert!(read(&conn, &store, "p1", "E2E_PASSWORD").is_err());
} }
#[test]
fn reveal_returns_original_value_by_kind_only_for_humans() {
let conn = fixture();
let store = MemoryCredentialStore::new();
register_test_account(&conn, "p1", "AGENT_ADMIN", "api", "local", "自验").unwrap();
register(&conn, "p1", "SANDBOX_KEY", "沙箱").unwrap();
assert!(reveal(&conn, &store, "p1", "AGENT_ADMIN").is_err());
assert!(reveal(&conn, &store, "p1", "MISSING").is_err());
save_test_account(&conn, &store, "p1", "AGENT_ADMIN", "synthetic-user", "synthetic-pass")
.unwrap();
save(&conn, &store, "p1", "SANDBOX_KEY", "synthetic-key").unwrap();
assert_eq!(
serde_json::to_value(reveal(&conn, &store, "p1", "agent_admin").unwrap()).unwrap(),
serde_json::json!({"kind": "test_account", "username": "synthetic-user", "password": "synthetic-pass"})
);
assert_eq!(
serde_json::to_value(reveal(&conn, &store, "p1", "SANDBOX_KEY").unwrap()).unwrap(),
serde_json::json!({"kind": "scalar", "value": "synthetic-key"})
);
revoke(&conn, &store, "p1", "AGENT_ADMIN").unwrap();
assert!(reveal(&conn, &store, "p1", "AGENT_ADMIN").is_err());
// 人用投影不得进入 agent 通道。
let tools = crate::mcp_tools::tools_list_result().to_string();
assert!(!tools.contains("reveal"));
}
#[test] #[test]
fn test_account_is_one_idempotent_request_and_one_protected_value() { fn test_account_is_one_idempotent_request_and_one_protected_value() {
let conn = fixture(); let conn = fixture();

View File

@ -26,7 +26,7 @@ const TMPL_CAPABILITY_PACKS: &str =
const TMPL_MISE: &str = include_str!("../../resources/onboarding/mise.toml.tmpl"); const TMPL_MISE: &str = include_str!("../../resources/onboarding/mise.toml.tmpl");
const PROJECT_CONTEXT_SCHEMA_VERSION: u64 = 1; const PROJECT_CONTEXT_SCHEMA_VERSION: u64 = 1;
const CAPABILITY_PACK_SCHEMA_VERSION: u64 = 1; const CAPABILITY_PACK_SCHEMA_VERSION: u64 = 1;
const BASE_ONBOARDING_PACK_VERSION: &str = "1.4.0"; const BASE_ONBOARDING_PACK_VERSION: &str = "1.5.0";
const TOOLCHAIN_PACK_VERSION: &str = "1.0.0"; const TOOLCHAIN_PACK_VERSION: &str = "1.0.0";
/// 单个模板的分发结果。 /// 单个模板的分发结果。

View File

@ -406,6 +406,7 @@ pub fn run() {
commands::dev_variables::list_project_dev_variables, commands::dev_variables::list_project_dev_variables,
commands::dev_variables::save_project_dev_variable, commands::dev_variables::save_project_dev_variable,
commands::dev_variables::save_project_test_account, commands::dev_variables::save_project_test_account,
commands::dev_variables::reveal_project_dev_variable,
commands::dev_variables::revoke_project_dev_variable, commands::dev_variables::revoke_project_dev_variable,
// host apps // host apps
commands::host_apps::get_host_apps, commands::host_apps::get_host_apps,

View File

@ -24,6 +24,7 @@ import {
gitPush, gitPush,
type GiteaInstance, type GiteaInstance,
} from "../../lib/commands"; } from "../../lib/commands";
import { confirm } from "@tauri-apps/plugin-dialog";
import { useUIStore } from "../../store/ui"; import { useUIStore } from "../../store/ui";
type BindMode = "create" | "migrate" | "link"; type BindMode = "create" | "migrate" | "link";
@ -98,7 +99,7 @@ export function GiteaInstancesSection() {
}; };
const handleDelete = async (inst: GiteaInstance) => { const handleDelete = async (inst: GiteaInstance) => {
if (!confirm(`删除实例「${inst.name}」?其下所有项目绑定会一并删除。`)) return; if (!(await confirm(`删除实例「${inst.name}」?其下所有项目绑定会一并删除。`, { kind: "warning" }))) return;
try { try {
await giteaDeleteInstance(inst.id); await giteaDeleteInstance(inst.id);
qc.invalidateQueries({ queryKey: ["gitea-instances"] }); qc.invalidateQueries({ queryKey: ["gitea-instances"] });
@ -308,7 +309,7 @@ export function GiteaPanel({ projectId, projectName, projectPath }: Props) {
}; };
const handleUnlink = async () => { const handleUnlink = async () => {
if (!confirm("解绑当前项目与 Gitea 仓库?(Gitea 侧仓库不会删除)")) return; if (!(await confirm("解绑当前项目与 Gitea 仓库?(Gitea 侧仓库不会删除)", { kind: "warning" }))) return;
try { try {
await giteaUnlinkRepo(projectId); await giteaUnlinkRepo(projectId);
refresh(); refresh();

View File

@ -2,10 +2,13 @@ import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
listProjectDevVariables, listProjectDevVariables,
revealProjectDevVariable,
saveProjectDevVariable, saveProjectDevVariable,
saveProjectTestAccount, saveProjectTestAccount,
revokeProjectDevVariable, revokeProjectDevVariable,
type ProjectDevVariableStatus,
} from "../../lib/commands"; } from "../../lib/commands";
import { confirm } from "@tauri-apps/plugin-dialog";
import { useUIStore } from "../../store/ui"; import { useUIStore } from "../../store/ui";
import { Dialog } from "../ui/Dialog"; import { Dialog } from "../ui/Dialog";
@ -23,6 +26,8 @@ export function ProjectDevVariablesPanel({ projectId, projectName, onClose }: Pr
const [draftUsername, setDraftUsername] = useState(""); const [draftUsername, setDraftUsername] = useState("");
const [draftPassword, setDraftPassword] = useState(""); const [draftPassword, setDraftPassword] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [showSecret, setShowSecret] = useState(false);
const [loadingName, setLoadingName] = useState<string | null>(null);
const { data: variables = [], isPending, error } = useQuery({ const { data: variables = [], isPending, error } = useQuery({
queryKey: ["projectDevVariables", projectId], queryKey: ["projectDevVariables", projectId],
queryFn: () => listProjectDevVariables(projectId), queryFn: () => listProjectDevVariables(projectId),
@ -32,7 +37,35 @@ export function ProjectDevVariablesPanel({ projectId, projectName, onClose }: Pr
setDraftValue(""); setDraftValue("");
setDraftUsername(""); setDraftUsername("");
setDraftPassword(""); setDraftPassword("");
setShowSecret(false);
}; };
// 已配置项进入修改时预填原值(密码默认遮罩);取回失败则提示并以空白表单继续。
const startEdit = async (item: ProjectDevVariableStatus) => {
clearDraft();
if (item.configured) {
setLoadingName(item.name);
try {
const original = await revealProjectDevVariable(projectId, item.name);
if (original.kind === "test_account") {
setDraftUsername(original.username);
setDraftPassword(original.password);
} else {
setDraftValue(original.value);
}
} catch (cause) {
showToast(`读取原值失败:${cause}`);
} finally {
setLoadingName(null);
}
}
setEditingName(item.name);
};
const secretToggle = (
<button type="button" onClick={() => setShowSecret((shown) => !shown)}
className="shrink-0 px-2 text-xs text-gray-500 hover:text-gray-700">
{showSecret ? "隐藏" : "显示"}
</button>
);
const handleSave = async (name: string, kind: "scalar" | "test_account") => { const handleSave = async (name: string, kind: "scalar" | "test_account") => {
if (saving || (kind === "test_account" ? !draftUsername || !draftPassword : !draftValue)) return; if (saving || (kind === "test_account" ? !draftUsername || !draftPassword : !draftValue)) return;
setSaving(true); setSaving(true);
@ -105,26 +138,32 @@ export function ProjectDevVariablesPanel({ projectId, projectName, onClose }: Pr
aria-label={`${item.name} 测试账号`} aria-label={`${item.name} 测试账号`}
className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm"
/> />
<input <div className="flex items-center gap-1">
type="password" <input
autoComplete="off" type={showSecret ? "text" : "password"}
value={draftPassword} autoComplete="off"
onChange={(event) => setDraftPassword(event.target.value)} value={draftPassword}
placeholder="测试密码" onChange={(event) => setDraftPassword(event.target.value)}
aria-label={`${item.name} 测试密码`} placeholder="测试密码"
className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" aria-label={`${item.name} 测试密码`}
/> className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm"
/>
{secretToggle}
</div>
</div> </div>
) : ( ) : (
<input <div className="flex items-center gap-1">
type="password" <input
autoComplete="off" type={showSecret ? "text" : "password"}
autoFocus autoComplete="off"
value={draftValue} autoFocus
onChange={(event) => setDraftValue(event.target.value)} value={draftValue}
placeholder="填写开发值" onChange={(event) => setDraftValue(event.target.value)}
className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" placeholder="填写开发值"
/> className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm"
/>
{secretToggle}
</div>
)} )}
<div className="flex gap-2"> <div className="flex gap-2">
<button type="submit" disabled={saving || (item.kind === "test_account" ? !draftUsername || !draftPassword : !draftValue)} <button type="submit" disabled={saving || (item.kind === "test_account" ? !draftUsername || !draftPassword : !draftValue)}
@ -137,13 +176,15 @@ export function ProjectDevVariablesPanel({ projectId, projectName, onClose }: Pr
</form> </form>
) : ( ) : (
<div className="flex gap-3 text-xs"> <div className="flex gap-3 text-xs">
<button onClick={() => { clearDraft(); setEditingName(item.name); }} <button onClick={() => void startEdit(item)} disabled={loadingName === item.name}
className="text-blue-600 hover:underline">{item.configured ? "修改" : "填写"}</button> className="text-blue-600 hover:underline disabled:opacity-50">
{loadingName === item.name ? "读取中…" : item.configured ? "修改" : "填写"}
</button>
{item.configured && ( {item.configured && (
<button <button
disabled={revokeMutation.isPending} disabled={revokeMutation.isPending}
onClick={() => { onClick={async () => {
if (window.confirm(`撤销 ${item.name}?Agent 将无法再读取该值。`)) { if (await confirm(`撤销 ${item.name}?Agent 将无法再读取该值。`, { kind: "warning" })) {
revokeMutation.mutate(item.name); revokeMutation.mutate(item.name);
} }
}} }}

View File

@ -12,6 +12,7 @@ import {
type RuntimeToolStatus, type RuntimeToolStatus,
type ToolchainStatus, type ToolchainStatus,
} from "../../lib/commands"; } from "../../lib/commands";
import { confirm } from "@tauri-apps/plugin-dialog";
import { useUIStore } from "../../store/ui"; import { useUIStore } from "../../store/ui";
// ── 层元信息 ─────────────────────────────────────────────────────────────────── // ── 层元信息 ───────────────────────────────────────────────────────────────────
@ -113,8 +114,9 @@ export function ProjectEnvModal({ project, onClose }: Props) {
}; };
const handlePrepareToolchain = async () => { const handlePrepareToolchain = async () => {
const confirmed = window.confirm( const confirmed = await confirm(
"炼境将通过 mise install 下载并准备项目已声明的 Node/pnpm 版本。不会修改 package.json 或 lockfile,是否继续?", "炼境将通过 mise install 下载并准备项目已声明的 Node/pnpm 版本。不会修改 package.json 或 lockfile,是否继续?",
{ kind: "warning" },
); );
if (!confirmed) return; if (!confirmed) return;
setPreparing(true); setPreparing(true);

View File

@ -7,6 +7,7 @@ import {
type GitRepo, type GitRepo,
type UpdateGitRepoDeployInput, type UpdateGitRepoDeployInput,
} from "../../lib/commands"; } from "../../lib/commands";
import { confirm } from "@tauri-apps/plugin-dialog";
import { useUIStore } from "../../store/ui"; import { useUIStore } from "../../store/ui";
// ── 部署信息编辑表单 ────────────────────────────────────────────────────────── // ── 部署信息编辑表单 ──────────────────────────────────────────────────────────
@ -127,7 +128,7 @@ export function RegistryTab() {
}); });
const handleDelete = async (id: string, name: string) => { const handleDelete = async (id: string, name: string) => {
if (!confirm(`确定删除仓库「${name}」?关联的工作区也会被删除。`)) return; if (!(await confirm(`确定删除仓库「${name}」?关联的工作区也会被删除。`, { kind: "warning" }))) return;
try { try {
await deleteGitRepo(id); await deleteGitRepo(id);
qc.invalidateQueries({ queryKey: ["git-repos"] }); qc.invalidateQueries({ queryKey: ["git-repos"] });

View File

@ -1431,6 +1431,14 @@ export const saveProjectTestAccount = (
password: string, password: string,
) => invoke<void>("save_project_test_account", { projectId, name, username, password }); ) => invoke<void>("save_project_test_account", { projectId, name, username, password });
/** 仅在人显式点「修改」时取回原值;不经 MCP。 */
export type ProjectDevVariableReveal =
| { kind: "scalar"; value: string }
| { kind: "test_account"; username: string; password: string };
export const revealProjectDevVariable = (projectId: string, name: string) =>
invoke<ProjectDevVariableReveal>("reveal_project_dev_variable", { projectId, name });
export const revokeProjectDevVariable = (projectId: string, name: string) => export const revokeProjectDevVariable = (projectId: string, name: string) =>
invoke<void>("revoke_project_dev_variable", { projectId, name }); invoke<void>("revoke_project_dev_variable", { projectId, name });

View File

@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
// tauri-plugin-dialog 把 window.confirm 替换成 async 函数:同步写法拿到的是永远为真的 Promise,
// 确认框形同虚设(2026-09-30 撤销/删除未弹框即执行)。破坏性操作必须 await plugin-dialog 的 confirm。
const sources = import.meta.glob<string>(["../**/*.{ts,tsx}", "!../**/*.test.ts"], {
query: "?raw",
import: "default",
eager: true,
});
describe("confirm usage", () => {
it("scans the frontend sources", () => {
expect(Object.keys(sources).length).toBeGreaterThan(10);
});
it("never calls confirm without await", () => {
const offenders = Object.entries(sources).flatMap(([file, text]) =>
text
.split("\n")
.map((line, index) => ({ line, at: `${file}:${index + 1}` }))
.filter(({ line }) => /(^|[^\w.])(window\.)?confirm\(/.test(line) && !/await\s+(window\.)?confirm\(/.test(line))
.map(({ at }) => at),
);
expect(offenders).toEqual([]);
});
});