feat: purge legacy agent hosts and land vault Chrome extension path

Retire ACP/Hermes/OpenCode surfaces and rename hermes_tools to
mnote_agent_tools so Page AI stays on Pi Lab only. Add Chrome vault
extension + extension token route, pre-release purge design, and soft-retire
legacy smokes for the small-group production cut.
This commit is contained in:
Agent Board
2026-07-25 14:25:37 +08:00
parent bc6f8488ee
commit 262e66b02e
137 changed files with 9018 additions and 46049 deletions
+11 -4
View File
@@ -5,8 +5,8 @@ use crate::store::{
self, append_vault_audit, ensure_vault_directories, get_credential, list_credentials,
load_cipher_book, login_session_is_fresh, now_rfc3339, project_item_l0_with_cipher,
project_list_entry_with_cipher, project_secret_revealed, put_login_playbook, put_login_session,
resolve_record_secret_field, resolve_secret_with_cipher_book, VaultCredentialRecord,
VaultItemStatus, VaultLoginPlaybook, VaultLoginSession,
resolve_login_session, resolve_record_secret_field, resolve_secret_with_cipher_book,
VaultCredentialRecord, VaultItemStatus, VaultLoginPlaybook, VaultLoginSession,
};
use crate::token::DEFAULT_ACTOR;
use serde_json::{json, Value};
@@ -376,6 +376,7 @@ pub fn put_ai_vault_session(
expires_at: Some(expires.clone()),
last_login_at: Some(now),
source: Some(source.trim().to_string()),
..Default::default()
};
let record = put_login_session(&root, credential_id, session)?;
let _ = append_vault_audit(
@@ -432,8 +433,11 @@ pub fn login_ai_vault_credential(
)
});
// Prefer per-account session files (12-3); frontmatter is fallback inside resolve.
let resolved_session = resolve_login_session(&root, &record, None).ok().flatten();
if playbook.is_human_required() && !force_refresh {
if let Some(sess) = record.login_session.as_ref() {
if let Some(sess) = resolved_session.as_ref() {
if login_session_is_fresh(sess) {
let cookie = sess.cookie_header.clone().unwrap_or_default();
let _ = append_vault_audit(
@@ -452,6 +456,7 @@ pub fn login_ai_vault_credential(
"mode": "session",
"cookieHeader": cookie,
"expiresAt": sess.expires_at,
"accountId": sess.account_id,
"loginPlaybook": playbook,
"transcriptHint": "已复用登录态",
"note": "playbook=human_required;有可用 session 直接复用",
@@ -479,7 +484,7 @@ pub fn login_ai_vault_credential(
}
if !force_refresh {
if let Some(sess) = record.login_session.as_ref() {
if let Some(sess) = resolved_session.as_ref() {
if login_session_is_fresh(sess) {
let cookie = sess.cookie_header.clone().unwrap_or_default();
let _ = append_vault_audit(
@@ -499,6 +504,7 @@ pub fn login_ai_vault_credential(
"cookieHeader": cookie,
"expiresAt": sess.expires_at,
"source": sess.source,
"accountId": sess.account_id,
"loginPlaybook": {
"mode": playbook.mode,
"preferredAccount": playbook.preferred_account,
@@ -736,6 +742,7 @@ pub fn login_ai_vault_credential(
expires_at: Some(expires.clone()),
last_login_at: Some(now),
source: Some("api".into()),
..Default::default()
};
let _ = put_login_session(&root, credential_id, session)?;
let _ = append_vault_audit(
+757 -32
View File
@@ -168,8 +168,32 @@ pub struct VaultLoginPlaybook {
pub human_note: Option<String>,
}
/// Structured cookie from chrome.cookies / browser capture (secret values).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
pub struct VaultSessionCookie {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub domain: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub secure: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub http_only: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub same_site: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expiration_date: Option<f64>,
}
/// Captured browser/API session for multi-agent reuse (cookie header is secret).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
///
/// **Disk truth (12-3):** `sessions/{credId}/{accountId}.json`.
/// Frontmatter `loginSession` is read-only fallback for pre-12-3 data.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
pub struct VaultLoginSession {
/// Full Cookie request header value (secret).
@@ -179,11 +203,44 @@ pub struct VaultLoginSession {
pub expires_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_login_at: Option<String>,
/// api | browser | human_bridge
/// chrome_extension | api | browser | human_bridge | login_api
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub origin: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub account_id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cookies: Vec<VaultSessionCookie>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revision: Option<u64>,
}
/// On-disk session file schema (`mnote.vault.session.v1`).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct VaultSessionFile {
pub schema: String,
pub credential_id: String,
pub account_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cookie_header: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cookies: Vec<VaultSessionCookie>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub origin: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_login_at: Option<String>,
pub source: String,
pub updated_at: String,
pub revision: u64,
}
pub const VAULT_SESSION_SCHEMA: &str = "mnote.vault.session.v1";
pub const SESSION_ACCOUNT_PRIMARY: &str = "primary";
impl VaultLoginPlaybook {
pub fn default_for_credential(email: Option<&str>, username: Option<&str>) -> Self {
let preferred = if email.map(str::trim).filter(|s| !s.is_empty()).is_some() {
@@ -411,9 +468,11 @@ pub fn ensure_vault_directories(workspace_root: &Path) -> Result<PathBuf, WebErr
for relative in [
"",
"entries",
"sessions",
"attachments",
"trash",
"trash/entries",
"trash/sessions",
"trash/attachments",
] {
let path = if relative.is_empty() {
@@ -431,6 +490,359 @@ pub fn ensure_vault_directories(workspace_root: &Path) -> Result<PathBuf, WebErr
Ok(root)
}
/// Normalize account slot for session file name: empty / "primary" → `primary`.
pub fn normalize_session_account_id(account_id: Option<&str>) -> String {
let raw = account_id.map(str::trim).unwrap_or("");
if raw.is_empty() || raw.eq_ignore_ascii_case(SESSION_ACCOUNT_PRIMARY) {
SESSION_ACCOUNT_PRIMARY.to_string()
} else {
// reject path traversal
if raw.contains('/') || raw.contains('\\') || raw.contains("..") {
return SESSION_ACCOUNT_PRIMARY.to_string();
}
raw.to_string()
}
}
/// `sessions/{credId}/` under vault root.
pub fn session_dir_rel(credential_id: &str) -> String {
format!("sessions/{credential_id}")
}
/// `sessions/{credId}/{accountId}.json`
pub fn session_file_rel(credential_id: &str, account_id: Option<&str>) -> String {
let acc = normalize_session_account_id(account_id);
format!("sessions/{credential_id}/{acc}.json")
}
fn session_dir_abs(vault: &Path, credential_id: &str) -> PathBuf {
vault.join("sessions").join(credential_id)
}
fn session_file_abs(vault: &Path, credential_id: &str, account_id: Option<&str>) -> PathBuf {
let acc = normalize_session_account_id(account_id);
session_dir_abs(vault, credential_id).join(format!("{acc}.json"))
}
/// Build Cookie header from structured cookies (preferred when both present).
pub fn cookie_header_from_cookies(cookies: &[VaultSessionCookie]) -> String {
cookies
.iter()
.filter_map(|c| {
let name = c.name.trim();
if name.is_empty() {
return None;
}
let value = c.value.as_deref().unwrap_or("").trim();
Some(format!("{name}={value}"))
})
.collect::<Vec<_>>()
.join("; ")
}
fn session_file_to_login(file: &VaultSessionFile) -> VaultLoginSession {
VaultLoginSession {
cookie_header: file.cookie_header.clone(),
expires_at: file.expires_at.clone(),
last_login_at: file.last_login_at.clone(),
source: Some(file.source.clone()),
origin: file.origin.clone(),
account_id: Some(file.account_id.clone()),
cookies: file.cookies.clone(),
revision: Some(file.revision),
}
}
fn read_session_file_at(path: &Path) -> Result<Option<VaultSessionFile>, WebError> {
if !path.exists() {
return Ok(None);
}
let raw = fs::read_to_string(path).map_err(|error| {
WebError::bad_request_code(
"vault_session_read_failed",
format!("无法读取登录态文件: {error}"),
)
})?;
let file: VaultSessionFile = serde_json::from_str(&raw).map_err(|error| {
WebError::bad_request_code(
"vault_session_parse_failed",
format!("登录态文件 JSON 无效: {error}"),
)
})?;
Ok(Some(file))
}
/// Read one session file (no frontmatter fallback).
pub fn read_session_file(
workspace_root: &Path,
credential_id: &str,
account_id: Option<&str>,
) -> Result<Option<VaultLoginSession>, WebError> {
let vault = ensure_vault_directories(workspace_root)?;
let path = session_file_abs(&vault, credential_id, account_id);
Ok(read_session_file_at(&path)?.map(|f| session_file_to_login(&f)))
}
/// List accountIds that have a session file for this credential.
pub fn list_session_account_ids(
workspace_root: &Path,
credential_id: &str,
) -> Result<Vec<String>, WebError> {
let vault = ensure_vault_directories(workspace_root)?;
let dir = session_dir_abs(&vault, credential_id);
if !dir.exists() {
return Ok(Vec::new());
}
let mut ids = Vec::new();
let entries = fs::read_dir(&dir).map_err(|error| {
WebError::bad_request_code(
"vault_session_list_failed",
format!("无法列出登录态目录: {error}"),
)
})?;
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if let Some(stem) = name.strip_suffix(".json") {
if !stem.is_empty() {
ids.push(stem.to_string());
}
}
}
ids.sort();
Ok(ids)
}
/// Resolve session for login: explicit account → primary → accounts[0] → frontmatter.
pub fn resolve_login_session(
workspace_root: &Path,
record: &VaultCredentialRecord,
account_id: Option<&str>,
) -> Result<Option<VaultLoginSession>, WebError> {
let vault = ensure_vault_directories(workspace_root)?;
if let Some(aid) = account_id.map(str::trim).filter(|s| !s.is_empty()) {
if let Some(sess) = read_session_file_at(&session_file_abs(&vault, &record.id, Some(aid)))?
.map(|f| session_file_to_login(&f))
{
return Ok(Some(sess));
}
// explicit account miss: do not fall through to other accounts
return Ok(record
.login_session
.clone()
.filter(|s| s.cookie_header.as_ref().map(|c| !c.trim().is_empty()).unwrap_or(false)));
}
// primary file
if let Some(sess) = read_session_file_at(&session_file_abs(
&vault,
&record.id,
Some(SESSION_ACCOUNT_PRIMARY),
))?
.map(|f| session_file_to_login(&f))
{
return Ok(Some(sess));
}
// first multi-account slot
if let Some(first) = record.accounts.first() {
let aid = first.id.trim();
if !aid.is_empty() {
if let Some(sess) =
read_session_file_at(&session_file_abs(&vault, &record.id, Some(aid)))?
.map(|f| session_file_to_login(&f))
{
return Ok(Some(sess));
}
}
}
// any other session file
for aid in list_session_account_ids(workspace_root, &record.id)? {
if aid == SESSION_ACCOUNT_PRIMARY {
continue;
}
if let Some(sess) =
read_session_file_at(&session_file_abs(&vault, &record.id, Some(&aid)))?
.map(|f| session_file_to_login(&f))
{
return Ok(Some(sess));
}
}
// frontmatter fallback (legacy)
Ok(record.login_session.clone().filter(|s| {
s.cookie_header
.as_ref()
.map(|c| !c.trim().is_empty())
.unwrap_or(false)
}))
}
/// Whether credential has any usable session (files or frontmatter).
pub fn credential_has_login_session(
workspace_root: &Path,
record: &VaultCredentialRecord,
) -> bool {
if let Ok(ids) = list_session_account_ids(workspace_root, &record.id) {
if !ids.is_empty() {
return true;
}
}
record
.login_session
.as_ref()
.and_then(|s| s.cookie_header.as_ref())
.map(|c| !c.trim().is_empty())
.unwrap_or(false)
}
fn best_session_expires_at(
workspace_root: &Path,
record: &VaultCredentialRecord,
) -> Option<String> {
if let Ok(Some(sess)) = resolve_login_session(workspace_root, record, None) {
return sess.expires_at;
}
record
.login_session
.as_ref()
.and_then(|s| s.expires_at.clone())
}
fn remove_sessions_dir(vault: &Path, credential_id: &str) {
let dir = session_dir_abs(vault, credential_id);
if dir.exists() {
let _ = fs::remove_dir_all(&dir);
}
}
fn move_sessions_dir(vault: &Path, credential_id: &str, to_trash: bool) {
let active = session_dir_abs(vault, credential_id);
let trash = vault.join("trash").join("sessions").join(credential_id);
if to_trash {
if active.exists() {
let _ = fs::create_dir_all(trash.parent().unwrap_or(vault));
let _ = move_path(&active, &trash);
}
} else if trash.exists() {
let _ = fs::create_dir_all(active.parent().unwrap_or(vault));
let _ = move_path(&trash, &active);
}
}
/// Copy `sessions/{src_id}/**` → `sessions/{dst_id}/**` (share-to-ai).
pub fn copy_session_files(
source_workspace: &Path,
target_workspace: &Path,
source_id: &str,
target_id: &str,
) -> Result<usize, WebError> {
let src_vault = ensure_vault_directories(source_workspace)?;
let dst_vault = ensure_vault_directories(target_workspace)?;
let src_dir = session_dir_abs(&src_vault, source_id);
if !src_dir.exists() {
// migrate frontmatter-only session into target primary.json if present
return Ok(0);
}
let dst_dir = session_dir_abs(&dst_vault, target_id);
fs::create_dir_all(&dst_dir).map_err(|error| {
WebError::bad_request_code(
"vault_session_copy_failed",
format!("无法创建目标登录态目录: {error}"),
)
})?;
let mut count = 0usize;
let entries = fs::read_dir(&src_dir).map_err(|error| {
WebError::bad_request_code(
"vault_session_copy_failed",
format!("无法读取源登录态目录: {error}"),
)
})?;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let name = entry.file_name();
let dst_path = dst_dir.join(&name);
// rewrite credentialId inside file
if let Ok(Some(mut file)) = read_session_file_at(&path) {
file.credential_id = target_id.to_string();
file.updated_at = now_rfc3339();
let json = serde_json::to_string_pretty(&file).map_err(|e| {
WebError::bad_request_code(
"vault_session_copy_failed",
format!("序列化登录态失败: {e}"),
)
})?;
atomic_write_string(&dst_path, &json)?;
count += 1;
} else {
let _ = fs::copy(&path, &dst_path);
count += 1;
}
}
Ok(count)
}
fn atomic_write_string(path: &Path, content: &str) -> Result<(), WebError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| {
WebError::bad_request_code(
"vault_session_write_failed",
format!("无法创建目录: {error}"),
)
})?;
}
let tmp = path.with_extension("json.tmp");
{
let mut f = fs::File::create(&tmp).map_err(|error| {
WebError::bad_request_code(
"vault_session_write_failed",
format!("无法创建临时文件: {error}"),
)
})?;
f.write_all(content.as_bytes()).map_err(|error| {
WebError::bad_request_code(
"vault_session_write_failed",
format!("无法写入登录态: {error}"),
)
})?;
f.sync_all().ok();
}
fs::rename(&tmp, path).map_err(|error| {
WebError::bad_request_code(
"vault_session_write_failed",
format!("无法落盘登录态: {error}"),
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
}
Ok(())
}
fn patch_index_session_meta(
workspace_root: &Path,
credential_id: &str,
has_session: bool,
expires_at: Option<String>,
) -> Result<(), WebError> {
let vault = ensure_vault_directories(workspace_root)?;
let mut index = load_index(&vault)?;
if let Some(entry) = index.entries.get_mut(credential_id) {
entry.has_login_session = has_session;
entry.session_expires_at = expires_at;
index.revision = index.revision.saturating_add(1);
index.updated_at = now_rfc3339();
write_index_atomic(&vault, &index)?;
}
Ok(())
}
pub fn now_rfc3339() -> String {
OffsetDateTime::now_utc()
.format(&Rfc3339)
@@ -1165,6 +1577,8 @@ fn index_entry_from_record(record: &VaultCredentialRecord) -> VaultIndexEntry {
revision: record.revision,
shared_to_ai: record.shared_to_ai.clone(),
shared_from: record.shared_from.clone(),
// Index is written without workspace path for file scan; use frontmatter
// flag here. List/get projections re-check session files when workspace known.
has_login_session: record
.login_session
.as_ref()
@@ -1183,6 +1597,23 @@ fn index_entry_from_record(record: &VaultCredentialRecord) -> VaultIndexEntry {
}
}
/// Refresh has_login_session / session_expires_at from disk session files.
pub fn enrich_index_entry_session(
workspace_root: &Path,
entry: &mut VaultIndexEntry,
) {
if let Ok(ids) = list_session_account_ids(workspace_root, &entry.id) {
if !ids.is_empty() {
entry.has_login_session = true;
if let Ok(record) = get_credential(workspace_root, &entry.id) {
entry.session_expires_at = best_session_expires_at(workspace_root, &record);
}
return;
}
}
// keep frontmatter-derived flags already on entry
}
/// Normalize logical folder path: trim, collapse `//`, strip leading/trailing `/`.
pub fn normalize_folder_path(raw: Option<&str>) -> Option<String> {
let Some(s) = raw.map(str::trim).filter(|v| !v.is_empty()) else {
@@ -2178,6 +2609,7 @@ fn parse_login_session(map: &BTreeMap<String, Value>) -> Option<VaultLoginSessio
expires_at,
last_login_at,
source,
..Default::default()
})
}
@@ -2366,6 +2798,9 @@ pub fn list_credentials(
.filter(|entry| entry.status == status)
.cloned()
.collect();
for entry in &mut items {
enrich_index_entry_session(workspace_root, entry);
}
items.sort_by(|a, b| b.updated_at.cmp(&a.updated_at).then(a.title.cmp(&b.title)));
Ok((index, items))
}
@@ -2590,6 +3025,9 @@ pub fn sync_shared_ai_copy(
});
write_record_with_index(target_workspace, &target)?;
// Keep AI copy sessions in sync with source session files.
let _ = copy_session_files(source_workspace, target_workspace, &source.id, target_id)?;
let mut source_updated = source.clone();
source_updated.shared_to_ai = Some(VaultShareToAi {
target_actor_id: target_actor_id.to_string(),
@@ -2707,6 +3145,27 @@ pub fn share_credential_to_workspace(
item.login_session = None;
write_record_with_index(target_workspace, &item)?;
// Copy per-account session files so Agent login can reuse cookies.
let _ = copy_session_files(source_workspace, target_workspace, id, &item.id)?;
// Legacy frontmatter-only session → write primary on target when no files copied.
if !credential_has_login_session(target_workspace, &item) {
if let Some(sess) = source.login_session.clone().filter(|s| {
s.cookie_header
.as_ref()
.map(|c| !c.trim().is_empty())
.unwrap_or(false)
}) {
let _ = put_login_session(target_workspace, &item.id, sess);
}
}
// Refresh item with resolved session for callers.
if let Ok(mut refreshed) = get_credential(target_workspace, &item.id) {
if let Ok(Some(sess)) = resolve_login_session(target_workspace, &refreshed, None) {
refreshed.login_session = Some(sess);
}
item = refreshed;
}
let mut source_updated = source;
source_updated.shared_to_ai = Some(VaultShareToAi {
target_actor_id: target_actor_id.to_string(),
@@ -3224,6 +3683,7 @@ pub fn soft_delete_credential(
if active_att.exists() {
let _ = move_path(&active_att, &trash_att);
}
move_sessions_dir(&vault, id, true);
for att in &mut record.attachments {
if att.relative_path.starts_with("attachments/") {
att.relative_path = format!("trash/{}", att.relative_path);
@@ -3262,6 +3722,7 @@ pub fn restore_credential(
if trash_att.exists() {
let _ = move_path(&trash_att, &active_att);
}
move_sessions_dir(&vault, id, false);
for att in &mut record.attachments {
if let Some(rest) = att.relative_path.strip_prefix("trash/") {
att.relative_path = rest.to_string();
@@ -3310,6 +3771,12 @@ pub fn purge_credential(workspace_root: &Path, id: &str) -> Result<(), WebError>
if trash_att.exists() {
let _ = fs::remove_dir_all(&trash_att);
}
// purge active + trash session dirs
remove_sessions_dir(&vault, id);
let trash_sess = vault.join("trash").join("sessions").join(id);
if trash_sess.exists() {
let _ = fs::remove_dir_all(&trash_sess);
}
index.entries.remove(id);
index.revision = index.revision.saturating_add(1);
index.updated_at = now_rfc3339();
@@ -3444,21 +3911,41 @@ pub fn project_item_l0_with_cipher(
"isSharedToAi": record.shared_to_ai.is_some(),
"isAiSharedCopy": record.shared_from.is_some()
|| record.tags.iter().any(|t| t == "ai-shared"),
"hasLoginSession": record
.login_session
.as_ref()
.and_then(|s| s.cookie_header.as_ref())
.map(|c| !c.trim().is_empty())
.unwrap_or(false),
"sessionExpiresAt": record
.login_session
.as_ref()
.and_then(|s| s.expires_at.clone()),
"lastLoginAt": record
.login_session
.as_ref()
.and_then(|s| s.last_login_at.clone()),
"hasLoginSession": false,
"sessionExpiresAt": Value::Null,
"lastLoginAt": Value::Null,
});
// Prefer session files when workspace known; else frontmatter.
let resolved_sess = workspace_root
.and_then(|root| resolve_login_session(root, record, None).ok().flatten())
.or_else(|| record.login_session.clone());
if let Some(sess) = resolved_sess.as_ref() {
let has = sess
.cookie_header
.as_ref()
.map(|c| !c.trim().is_empty())
.unwrap_or(false);
item["hasLoginSession"] = json!(has);
item["sessionExpiresAt"] = json!(sess.expires_at);
item["lastLoginAt"] = json!(sess.last_login_at);
// Never project cookieHeader / cookies[].value in L0.
item["loginSession"] = json!({
"hasCookie": has,
"expiresAt": sess.expires_at,
"lastLoginAt": sess.last_login_at,
"source": sess.source,
"accountId": sess.account_id,
"origin": sess.origin,
});
}
if let Some(root) = workspace_root {
if let Ok(ids) = list_session_account_ids(root, &record.id) {
if !ids.is_empty() {
item["sessionAccountIds"] = json!(ids);
item["hasLoginSession"] = json!(true);
}
}
}
if let Some(t) = username_template {
item["usernameTemplate"] = json!(t);
}
@@ -3483,15 +3970,6 @@ pub fn project_item_l0_with_cipher(
"humanNote": pb.human_note,
});
}
// Never project cookieHeader in L0.
if let Some(sess) = &record.login_session {
item["loginSession"] = json!({
"hasCookie": sess.cookie_header.as_ref().map(|c| !c.trim().is_empty()).unwrap_or(false),
"expiresAt": sess.expires_at,
"lastLoginAt": sess.last_login_at,
"source": sess.source,
});
}
item
}
@@ -3559,24 +4037,116 @@ pub fn project_list_entry(entry: &VaultIndexEntry) -> Value {
project_list_entry_with_cipher(entry, None)
}
/// Persist login session on a credential (AI vault session write-back).
/// Persist login session as `sessions/{id}/{accountId}.json` (12-3).
/// Does **not** write cookie into credential frontmatter (legacy field left as-is or cleared).
pub fn put_login_session(
workspace_root: &Path,
id: &str,
session: VaultLoginSession,
) -> Result<VaultCredentialRecord, WebError> {
let mut record = get_credential(workspace_root, id)?;
let account_id = session.account_id.clone();
put_login_session_for_account(workspace_root, id, account_id.as_deref(), session)
}
/// Write session file for a specific account slot (`primary` when omitted).
pub fn put_login_session_for_account(
workspace_root: &Path,
id: &str,
account_id: Option<&str>,
mut session: VaultLoginSession,
) -> Result<VaultCredentialRecord, WebError> {
let record = get_credential(workspace_root, id)?;
if record.status != VaultItemStatus::Active {
return Err(WebError::bad_request_code(
"vault_session_inactive",
"只能给在用条目写入登录态",
));
}
record.login_session = Some(session);
record.updated_at = now_rfc3339();
record.revision = record.revision.saturating_add(1);
write_record_with_index(workspace_root, &record)?;
Ok(record)
// Prefer structured cookies for header when both present.
if !session.cookies.is_empty() {
let rebuilt = cookie_header_from_cookies(&session.cookies);
if !rebuilt.is_empty() {
session.cookie_header = Some(rebuilt);
}
}
let cookie = session
.cookie_header
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.ok_or_else(|| {
WebError::bad_request_code(
"vault_session_cookie_required",
"cookieHeader 与 cookies[] 至少一个非空",
)
})?;
let acc = normalize_session_account_id(
account_id.or(session.account_id.as_deref()),
);
let vault = ensure_vault_directories(workspace_root)?;
let path = session_file_abs(&vault, id, Some(&acc));
let prev_rev = read_session_file_at(&path)?
.map(|f| f.revision)
.unwrap_or(0);
let now = now_rfc3339();
let expires = session
.expires_at
.clone()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| {
let hours = std::env::var("MNOTE_VAULT_SESSION_TTL_HOURS")
.ok()
.and_then(|s| s.trim().parse().ok())
.filter(|h: &i64| *h > 0)
.unwrap_or(168);
(OffsetDateTime::now_utc() + time::Duration::hours(hours))
.format(&Rfc3339)
.unwrap_or_else(|_| now.clone())
});
let source = session
.source
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("api")
.to_string();
let last_login = session
.last_login_at
.clone()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| now.clone());
let file = VaultSessionFile {
schema: VAULT_SESSION_SCHEMA.into(),
credential_id: id.to_string(),
account_id: acc.clone(),
cookie_header: Some(cookie.to_string()),
cookies: session.cookies.clone(),
origin: session.origin.clone(),
expires_at: Some(expires.clone()),
last_login_at: Some(last_login),
source,
updated_at: now,
revision: prev_rev.saturating_add(1),
};
let json = serde_json::to_string_pretty(&file).map_err(|e| {
WebError::bad_request_code(
"vault_session_write_failed",
format!("序列化登录态失败: {e}"),
)
})?;
atomic_write_string(&path, &json)?;
// Index meta only — do not bump credential revision / rewrite md for cookie.
patch_index_session_meta(workspace_root, id, true, Some(expires))?;
// Return record with resolved session for callers that inspect login_session.
let mut out = record;
out.login_session = Some(session_file_to_login(&file));
Ok(out)
}
pub fn put_login_playbook(
@@ -4409,4 +4979,159 @@ mod tests {
assert_eq!(created.accounts[0].password.as_deref(), Some("secret"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn session_file_put_resolve_and_share_copy() {
let src = temp_root();
let dst = temp_root();
let created = create_credential(
&src,
VaultCreateInput {
title: "session-file-test".into(),
url: Some("https://example.com/login".into()),
username: Some("user@ex.com".into()),
password: Some("secret".into()),
..Default::default()
},
)
.unwrap();
let put = put_login_session(
&src,
&created.id,
VaultLoginSession {
// When cookies[] present, header is rebuilt from them (preferred).
cookie_header: Some("ignored-if-cookies-present".into()),
expires_at: Some("2099-01-01T00:00:00Z".into()),
last_login_at: Some("2026-07-24T12:00:00Z".into()),
source: Some("chrome_extension".into()),
origin: Some("https://example.com".into()),
account_id: None,
cookies: vec![
VaultSessionCookie {
name: "sid".into(),
value: Some("abc".into()),
domain: Some(".example.com".into()),
path: Some("/".into()),
secure: Some(true),
http_only: Some(true),
same_site: Some("lax".into()),
expiration_date: None,
},
VaultSessionCookie {
name: "csrf".into(),
value: Some("xyz".into()),
domain: Some(".example.com".into()),
path: Some("/".into()),
secure: Some(true),
http_only: Some(false),
same_site: Some("lax".into()),
expiration_date: None,
},
],
revision: None,
},
)
.unwrap();
assert_eq!(
put.login_session
.as_ref()
.and_then(|s| s.cookie_header.as_deref()),
Some("sid=abc; csrf=xyz")
);
let path = vault_root(&src)
.join("sessions")
.join(&created.id)
.join("primary.json");
assert!(path.exists(), "session file should exist at {:?}", path);
let raw = fs::read_to_string(&path).unwrap();
assert!(raw.contains("mnote.vault.session.v1"));
assert!(raw.contains("chrome_extension"));
// credential md must not contain cookie plaintext after file write
let md = fs::read_to_string(vault_root(&src).join(&created.relative_path)).unwrap();
assert!(!md.contains("sid=abc"));
let resolved = resolve_login_session(&src, &created, None).unwrap().unwrap();
assert_eq!(
resolved.cookie_header.as_deref(),
Some("sid=abc; csrf=xyz")
);
assert!(login_session_is_fresh(&resolved));
let (_, list) = list_credentials(&src, VaultItemStatus::Active).unwrap();
let entry = list.iter().find(|e| e.id == created.id).unwrap();
assert!(entry.has_login_session);
let l0 = project_item_l0_with_cipher(&created, Some(&src));
assert_eq!(l0["hasLoginSession"], true);
assert!(!l0.to_string().contains("sid=abc"));
let _ = put_login_session_for_account(
&src,
&created.id,
Some("acc_alt"),
VaultLoginSession {
cookie_header: Some("sid=alt".into()),
expires_at: Some("2099-06-01T00:00:00Z".into()),
source: Some("api".into()),
..Default::default()
},
)
.unwrap();
let ids = list_session_account_ids(&src, &created.id).unwrap();
assert!(ids.contains(&"primary".to_string()));
assert!(ids.contains(&"acc_alt".to_string()));
let shared = share_credential_to_workspace(
&src,
&dst,
&created.id,
Some("from-user"),
&[],
false,
"user-a",
"ai-agent",
)
.unwrap();
let dst_ids = list_session_account_ids(&dst, &shared.item.id).unwrap();
assert!(
dst_ids.contains(&"primary".to_string()),
"share must copy session files"
);
let dst_sess = resolve_login_session(&dst, &shared.item, None)
.unwrap()
.unwrap();
assert!(dst_sess
.cookie_header
.as_ref()
.map(|c| c.contains("sid=abc"))
.unwrap_or(false));
soft_delete_credential(&src, &created.id).unwrap();
assert!(!vault_root(&src)
.join("sessions")
.join(&created.id)
.exists());
assert!(vault_root(&src)
.join("trash")
.join("sessions")
.join(&created.id)
.exists());
restore_credential(&src, &created.id).unwrap();
assert!(vault_root(&src)
.join("sessions")
.join(&created.id)
.exists());
soft_delete_credential(&src, &created.id).unwrap();
purge_credential(&src, &created.id).unwrap();
assert!(!vault_root(&src)
.join("trash")
.join("sessions")
.join(&created.id)
.exists());
let _ = fs::remove_dir_all(&src);
let _ = fs::remove_dir_all(&dst);
}
}