Compare commits

..

No commits in common. "e1e98e2b7bce47a48d217dab949ae767ba3597bb" and "c7333038c563af69e103f825fe0642642285a374" have entirely different histories.

9 changed files with 15 additions and 215 deletions

View File

@ -68,13 +68,6 @@ progress: 100
## 任务拆解
### ✅ R9 — PR 审查修复:缓存失效、凭证删除与 readiness
- status: done
- complexity: M
- files: src/components/dashboard/ApiKeysPanel.tsx, src/components/dashboard/ApiKeysPanel.test.ts, src-tauri/src/credential_store.rs, src-tauri/src/commands/api_keys.rs, src-tauri/src/commands/gitea.rs, src/lib/commands.ts
- acceptance: "agent_verify: [cargo test] 凭证删除失败可恢复且不会假成功,legacy readiness 为 pending_migration 而非 configured;[vitest] copyCurrentApiKey 编辑后复制最新值;[CLI] typecheck、测试与 git diff --check 通过。human_verify: 正式安装版编辑后显示/复制终验。"
- human_verify: pending(正式安装版 UI 未在本轮重跑;agent 路径已通过)
### ✅ R1 — Gitea 与 webhook 输出脱敏
- status: done
- complexity: M

View File

@ -141,7 +141,7 @@ acceptance 是 agent 自验的唯一判断依据,必须是可执行断言:
## 项目资源与凭证代办边界
`get_project_resource_status(project_id)` 是同一项目绑定上的只读 MCP/Tauri 投影,只报告 Gitea PR 与 webhook 的 `configured`、`pending_migration`、`not_configured`、`unavailable` 或 `needs_repair` 及非秘密来源。旧 SQLite 凭证未迁移时只报告 `pending_migration`,不提前宣称可执行;scoped MCP 连接拒绝跨项目参数;状态不含原始凭证、凭证引用、仓库 URL 或连接串。`configured` 仅表示本机代办凭证就绪,不代表远端 Gitea、CI 或部署已验证。
`get_project_resource_status(project_id)` 是同一项目绑定上的只读 MCP/Tauri 投影,只报告 Gitea PR 与 webhook 的 `configured`、`not_configured`、`unavailable` 或 `needs_repair` 及非秘密来源。scoped MCP 连接拒绝跨项目参数;状态不含原始凭证、凭证引用、仓库 URL 或连接串。`configured` 仅表示本机代办凭证就绪,不代表远端 Gitea、CI 或部署已验证。
Gitea PR 继续使用既有 `create_pull_request` 有界操作;Windows 本机代办凭证由 Credential Manager 保护,SQLite 只存引用及非秘密元数据。旧值在显式操作时,先在数据库同目录以 SQLite `VACUUM INTO` 生成 `dev-manager.pre-credential-migration.db`,校验完整性和当前旧值确在备份中,再按写入、读取校验、事务切换引用并清除旧列的顺序迁移;备份不可用或失败则保留旧值并报告 `needs_repair`。此备份可能含历史明文,须按原数据库同等敏感级别保护,不能因当前列清空就宣称历史明文已消失。普通 Gitea/API Key DTO 不带原始值;人的 API Key 查看与复制仍通过本机 Tauri 的显式 reveal,MCP 不暴露该入口。Gitea Actions secrets、项目运行时密钥与服务器凭证仍留在各自原用途位置;炼境不提供 `get_secret` 或任意 SSH、SQL、HTTP、部署代理。同一 Windows 用户下的进程隔离不是本方案提供的安全保证。

View File

@ -15,7 +15,7 @@ contracts:
- "src-tauri/src/commands/toolchain.rs"
- "src-tauri/src/commands/cicd.rs"
- "src-tauri/src/commands/gitea.rs"
doc: "工具 schema、dispatch 与调用说明必须同步;get_project_resource_status 为项目 scoped 只读状态,仅返回 Gitea PR/webhook 的状态与来源,不返回凭证、引用、URL;旧凭证未迁移时返回 pending_migration,不提前宣称 configured。MCP 不提供 reveal_api_key、get_secret 或任意 HTTP/SSH/SQL/部署代理。production delivery profile 由项目维护,炼境只读 Gitea 部署 context 与公开版本探针,双证据相符才能报告 deployed。外部来源不可用时返回显式降级状态,不得伪造成功。verify_onboarding 必须同时返回静态接入 checks 与 configured/reachable/handshake/tools_ready/reload_required 运行态证据,配置存在不得替代运行可用。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。"
doc: "工具 schema、dispatch 与调用说明必须同步;get_project_resource_status 为项目 scoped 只读状态,仅返回 Gitea PR/webhook 的状态与来源,不返回凭证、引用、URL。MCP 不提供 reveal_api_key、get_secret 或任意 HTTP/SSH/SQL/部署代理。production delivery profile 由项目维护,炼境只读 Gitea 部署 context 与公开版本探针,双证据相符才能报告 deployed。外部来源不可用时返回显式降级状态,不得伪造成功。verify_onboarding 必须同时返回静态接入 checks 与 configured/reachable/handshake/tools_ready/reload_required 运行态证据,配置存在不得替代运行可用。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。"
- label: 被管理项目身份上下文
paths:

View File

@ -131,23 +131,17 @@ pub fn delete_api_key_fn(
store: &impl crate::credential_store::CredentialStore,
id: &str,
) -> Result<(), String> {
let tx = conn
.unchecked_transaction()
.map_err(|_| "删除 API Key 失败".to_string())?;
let reference: Option<String> = tx
let reference: Option<String> = conn
.query_row(
"SELECT credential_ref FROM api_keys WHERE id=?1",
params![id],
|row| row.get(0),
)
.map_err(|_| "API Key 不存在".to_string())?;
tx.execute("DELETE FROM api_keys WHERE id=?1", params![id])
conn.execute("DELETE FROM api_keys WHERE id=?1", params![id])
.map_err(|_| "删除 API Key 失败".to_string())?;
let references: Vec<String> = reference.into_iter().collect();
let snapshots = crate::credential_store::delete_references(store, &references)?;
if tx.commit().is_err() {
crate::credential_store::restore_references(store, &snapshots)?;
return Err("删除 API Key 失败:数据库提交失败".into());
if let Some(reference) = reference {
let _ = store.delete(&reference);
}
Ok(())
}
@ -307,23 +301,4 @@ mod tests {
delete_api_key_fn(&conn, &store, &key.id).unwrap();
assert!(store.get(&reference).unwrap().is_none());
}
#[test]
fn delete_failure_keeps_api_key_and_credential_reference() {
let conn = crate::db::conn_with_schema();
let store = crate::credential_store::MemoryCredentialStore::failing_deletes();
store.put("api-key/k1/v1", b"synthetic-key").unwrap();
conn.execute(
"INSERT INTO api_keys (id, name, provider, api_key, credential_ref) VALUES ('k1', 'key', 'test', '', 'api-key/k1/v1')",
[],
).unwrap();
assert!(delete_api_key_fn(&conn, &store, "k1")
.unwrap_err()
.contains("needs_repair"));
assert!(get_api_key(&conn, "k1").is_some());
assert_eq!(
store.get("api-key/k1/v1").unwrap().as_deref(),
Some(b"synthetic-key".as_slice())
);
}
}

View File

@ -110,7 +110,7 @@ fn credential_readiness(
if has_legacy {
match store.get("resource-readiness/probe") {
Ok(_) => ResourceReadiness {
state: "pending_migration",
state: "configured",
source: "legacy_pending_migration",
},
Err(_) => ResourceReadiness {
@ -277,19 +277,10 @@ fn delete_gitea_instance_with_store(
.map_err(|_| "删除 Gitea 实例失败".to_string())?
.collect::<rusqlite::Result<_>>()
.map_err(|_| "删除 Gitea 实例失败".to_string())?;
let tx = conn
.unchecked_transaction()
conn.execute("DELETE FROM gitea_instances WHERE id = ?1", params![id])
.map_err(|_| "删除 Gitea 实例失败".to_string())?;
tx.execute("DELETE FROM gitea_instances WHERE id = ?1", params![id])
.map_err(|_| "删除 Gitea 实例失败".to_string())?;
let references: Vec<String> = instance_reference
.into_iter()
.chain(repo_references)
.collect();
let snapshots = crate::credential_store::delete_references(store, &references)?;
if tx.commit().is_err() {
crate::credential_store::restore_references(store, &snapshots)?;
return Err("删除 Gitea 实例失败:数据库提交失败".into());
for reference in instance_reference.into_iter().chain(repo_references) {
let _ = store.delete(&reference);
}
Ok(())
}
@ -2246,72 +2237,6 @@ mod tests {
assert_eq!(default_count, 1, "失败的保存不应改变已有默认实例");
}
#[test]
fn deleting_instance_keeps_instance_and_repo_when_protected_store_delete_fails() {
let conn = crate::db::conn_with_schema();
let store = crate::credential_store::MemoryCredentialStore::failing_deletes();
store
.put("gitea/access-token/i/v1", b"synthetic-token")
.unwrap();
conn.execute(
"INSERT INTO gitea_instances (id, name, url, access_token, access_token_ref) VALUES ('i', 'test', 'https://example.invalid', '', 'gitea/access-token/i/v1')",
[],
).unwrap();
conn.execute(
"INSERT INTO gitea_repos (id, instance_id, project_id, gitea_owner, gitea_repo_name) VALUES ('r', 'i', 'p', 'owner', 'repo')",
[],
).unwrap();
assert!(delete_gitea_instance_with_store(&conn, &store, "i")
.unwrap_err()
.contains("needs_repair"));
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM gitea_repos WHERE id='r'", [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(count, 1);
assert_eq!(
store.get("gitea/access-token/i/v1").unwrap().as_deref(),
Some(b"synthetic-token".as_slice())
);
}
#[test]
fn deleting_instance_restores_token_when_later_webhook_delete_fails() {
let conn = crate::db::conn_with_schema();
let store = crate::credential_store::MemoryCredentialStore::failing_delete_for(
"gitea/webhook-secret/p/v1",
);
store
.put("gitea/access-token/i/v1", b"synthetic-token")
.unwrap();
store
.put("gitea/webhook-secret/p/v1", b"synthetic-hook")
.unwrap();
conn.execute("INSERT INTO gitea_instances (id, name, url, access_token, access_token_ref) VALUES ('i', 'test', 'https://example.invalid', '', 'gitea/access-token/i/v1')", []).unwrap();
conn.execute("INSERT INTO gitea_repos (id, instance_id, project_id, gitea_owner, gitea_repo_name, webhook_secret_ref) VALUES ('r', 'i', 'p', 'owner', 'repo', 'gitea/webhook-secret/p/v1')", []).unwrap();
assert!(delete_gitea_instance_with_store(&conn, &store, "i")
.unwrap_err()
.contains("needs_repair"));
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM gitea_instances WHERE id='i'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(count, 1);
assert_eq!(
store.get("gitea/access-token/i/v1").unwrap().as_deref(),
Some(b"synthetic-token".as_slice())
);
assert_eq!(
store.get("gitea/webhook-secret/p/v1").unwrap().as_deref(),
Some(b"synthetic-hook".as_slice())
);
}
#[test]
fn gitea_auth_migrates_legacy_token_and_update_preserves_old_on_store_failure() {
let conn = crate::db::conn_with_schema();
@ -2606,7 +2531,7 @@ mod tests {
conn.execute("INSERT INTO gitea_instances (id, name, url, access_token) VALUES ('i', 'test', 'https://secret-host.invalid', 'synthetic-token')", []).unwrap();
conn.execute("INSERT INTO gitea_repos (id, instance_id, project_id, gitea_owner, gitea_repo_name, webhook_secret) VALUES ('r', 'i', 'p1', 'sensitive-owner', 'sensitive-repo', 'synthetic-webhook-secret')", []).unwrap();
let legacy = project_resource_status_for_conn(&conn, &store, "p1").unwrap();
assert_eq!(legacy.gitea_pr.state, "pending_migration");
assert_eq!(legacy.gitea_pr.state, "configured");
assert_eq!(legacy.gitea_pr.source, "legacy_pending_migration");
let unavailable = project_resource_status_for_conn(
&conn,

View File

@ -95,42 +95,6 @@ pub fn put_verified(
}
}
/// 在数据库删除事务提交前移除凭证;若中途失败,用快照恢复已删除的引用。
pub fn delete_references(
store: &impl CredentialStore,
references: &[String],
) -> Result<Vec<(String, Option<Vec<u8>>)>, String> {
let mut snapshots = Vec::new();
for reference in references {
if !snapshots.iter().any(|(saved, _)| saved == reference) {
let value = store
.get(reference)
.map_err(|_| "凭证状态 needs_repair:删除前无法读取受保护存储".to_string())?;
snapshots.push((reference.clone(), value));
}
}
for index in 0..snapshots.len() {
if store.delete(&snapshots[index].0).is_err() {
restore_references(store, &snapshots[..=index])?;
return Err("凭证状态 needs_repair:受保护存储删除失败,数据库记录已保留".into());
}
}
Ok(snapshots)
}
pub fn restore_references(
store: &impl CredentialStore,
snapshots: &[(String, Option<Vec<u8>>)],
) -> Result<(), String> {
for (reference, value) in snapshots {
if let Some(value) = value {
put_verified(store, reference, value)
.map_err(|_| "凭证状态 needs_repair:受保护存储凭证恢复失败".to_string())?;
}
}
Ok(())
}
impl LegacyCredentialKind {
fn columns(self) -> (&'static str, &'static str, &'static str, &'static str, bool) {
match self {
@ -398,8 +362,6 @@ pub(crate) struct MemoryCredentialStore {
values: Mutex<HashMap<String, Vec<u8>>>,
fail_writes: bool,
fail_reads: bool,
fail_deletes: bool,
fail_delete_reference: Option<String>,
}
#[cfg(test)]
@ -409,8 +371,6 @@ impl MemoryCredentialStore {
values: Mutex::new(HashMap::new()),
fail_writes: false,
fail_reads: false,
fail_deletes: false,
fail_delete_reference: None,
}
}
pub(crate) fn failing() -> Self {
@ -418,26 +378,6 @@ impl MemoryCredentialStore {
values: Mutex::new(HashMap::new()),
fail_writes: true,
fail_reads: true,
fail_deletes: true,
fail_delete_reference: None,
}
}
pub(crate) fn failing_deletes() -> Self {
Self {
values: Mutex::new(HashMap::new()),
fail_writes: false,
fail_reads: false,
fail_deletes: true,
fail_delete_reference: None,
}
}
pub(crate) fn failing_delete_for(reference: &str) -> Self {
Self {
values: Mutex::new(HashMap::new()),
fail_writes: false,
fail_reads: false,
fail_deletes: false,
fail_delete_reference: Some(reference.into()),
}
}
pub(crate) fn contains_value(&self, value: &[u8]) -> bool {
@ -472,9 +412,6 @@ impl CredentialStore for MemoryCredentialStore {
.cloned())
}
fn delete(&self, reference: &str) -> Result<(), String> {
if self.fail_deletes || self.fail_delete_reference.as_deref() == Some(reference) {
return Err("模拟凭证存储删除失败".into());
}
self.values
.lock()
.map_err(|_| "凭证存储锁不可用".to_string())?

View File

@ -1,21 +0,0 @@
import { describe, expect, it } from "vitest";
import { copyCurrentApiKey } from "./ApiKeysPanel";
describe("copyCurrentApiKey", () => {
it("reads the current credential instead of reusing a previously revealed value", async () => {
let current = "synthetic-old-key";
let copied = "";
const reveal = async (id: string) => {
expect(id).toBe("k1");
return current;
};
const write = async (value: string) => { copied = value; };
await copyCurrentApiKey("k1", reveal, write);
expect(copied).toBe("synthetic-old-key");
current = "synthetic-new-key";
await copyCurrentApiKey("k1", reveal, write);
expect(copied).toBe("synthetic-new-key");
});
});

View File

@ -18,14 +18,6 @@ const PROVIDERS = [
{ value: "custom", label: "自定义", color: "bg-gray-100 text-gray-600 border-gray-200" },
];
export async function copyCurrentApiKey(
id: string,
reveal: (id: string) => Promise<string>,
write: (value: string) => Promise<void>,
): Promise<void> {
await write(await reveal(id));
}
function getProviderStyle(provider: string) {
return PROVIDERS.find((p) => p.value === provider)?.color ?? PROVIDERS[PROVIDERS.length - 1].color;
}
@ -152,7 +144,7 @@ function ApiKeyCard({ item, onEdit, onDelete }: {
};
const copy = async () => {
try {
await copyCurrentApiKey(item.id, revealApiKey, (value) => navigator.clipboard.writeText(value));
await navigator.clipboard.writeText(secret ?? await revealApiKey(item.id));
setError(undefined);
} catch (cause) {
setError(String(cause));
@ -257,7 +249,6 @@ export function ApiKeysPanel() {
const [showModal, setShowModal] = useState(false);
const [editingItem, setEditingItem] = useState<(ApiKey & { api_key: string }) | null>(null);
const [editError, setEditError] = useState<string>();
const [keyRevision, setKeyRevision] = useState(0);
const { data: keys = [] } = useQuery({
queryKey: ["api-keys"],
@ -271,7 +262,7 @@ export function ApiKeysPanel() {
const editMut = useMutation({
mutationFn: ({ id, input }: { id: string; input: SaveApiKeyInput }) => editApiKey(id, input),
onSuccess: () => { setKeyRevision((revision) => revision + 1); qc.invalidateQueries({ queryKey: ["api-keys"] }); setEditingItem(null); },
onSuccess: () => { qc.invalidateQueries({ queryKey: ["api-keys"] }); setEditingItem(null); },
});
const deleteMut = useMutation({
@ -308,7 +299,7 @@ export function ApiKeysPanel() {
{keys.map((k) => (
<ApiKeyCard
key={`${k.id}:${keyRevision}`}
key={k.id}
item={k}
onEdit={() => { void (async () => {
try {

View File

@ -1803,7 +1803,7 @@ export const giteaTestConnection = (instanceId: string) =>
invoke<GiteaUser>("gitea_test_connection", { instanceId });
export interface ResourceReadiness {
state: "configured" | "pending_migration" | "not_configured" | "unavailable" | "needs_repair";
state: "configured" | "not_configured" | "unavailable" | "needs_repair";
source: "protected_store" | "legacy_pending_migration" | "gitea_binding";
}