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:
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user