Files
hermes-agent/apps/desktop/electron/user-account-ipc.ts
T
m4 fb13457f6f
CI / Supply-chain scan (push) Has been cancelled
Docker Build, Test, and Publish / Detect affected areas (push) Has been cancelled
auto-fix lint issues & formatting / Generate eslint --fix patch (push) Has been cancelled
Nix flake check / Detect affected areas (push) Has been cancelled
Deploy Site / deploy-vercel (push) Has been cancelled
Deploy Site / deploy-docs (push) Has been cancelled
CI / Detect affected areas (push) Has been cancelled
Docker Build, Test, and Publish / build (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest-32-core) (push) Has been cancelled
CI / Desktop E2E (push) Has been cancelled
CI / Docs Site (push) Has been cancelled
CI / Deny unrelated histories (push) Has been cancelled
CI / Check contributors (push) Has been cancelled
Docker Build, Test, and Publish / build (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-latest-32-arm-core) (push) Has been cancelled
CI / Check uv.lock (push) Has been cancelled
CI / Check no committed infographics (push) Has been cancelled
CI / Profile artifact check (push) Has been cancelled
Docker Build, Test, and Publish / publish (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest-32-core) (push) Has been cancelled
Docker Build, Test, and Publish / publish (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-latest-32-arm-core) (push) Has been cancelled
Docker Build, Test, and Publish / merge (push) Has been cancelled
auto-fix lint issues & formatting / Apply patch (push) Has been cancelled
CI / Check no case-colliding filenames (push) Has been cancelled
CI / package-lock.json diff (push) Has been cancelled
CI / Lint Docker scripts (push) Has been cancelled
CI / Review label gate (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
CI / CI timing report (push) Has been cancelled
CI / Python tests (push) Has been cancelled
CI / OS-specific tests (push) Has been cancelled
CI / Python lints (push) Has been cancelled
CI / JS & TS checks (push) Has been cancelled
CI / Installer tests (push) Has been cancelled
CI / Rust tests (push) Has been cancelled
Nix flake check / nix flake check (push) Has been cancelled
feat: consolidate desktop and provider updates
2026-09-27 17:37:19 +08:00

598 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* user-account-ipc.ts — 用户账号会话的 IPC 边界(§21 DB-T1/DB-T2,U-1/U-3/U-5)。
*
* token 只活在 main 进程(内存 + safeStorage 落盘),renderer 永远拿不到
* access/refresh token——login 之后 renderer 只能拿到 profile 与状态。
*
* 401 反应式刷新:任何带令牌调用 401 → 用 refresh token 续一次重试;
* refresh 也被拒(session_inactive / refresh_reuse_or_invalid)= 服务端判定的
* 硬终点(U-3):**不清本地会话**,只上报 sessionExpired——在线时 renderer 照旧
* 落登录页,离线时本地会话仍放行(离线优先,2026-09-24 用户裁定)。真正的清零
* 只发生在 logout / unbind。
*
* 离线登录(同一裁定):断网时 login 回落到本地校验记住的凭据,把已在盘上的
* 会话解锁回登录态。它只是"别砖在门口"的临时通路,不是第二套运行模式——
* 不新建会话,也不放行登录以外的任何操作。
*
* U-5 机器-用户锁:登录成功当场校验本机 installation ∈ 该用户的列表;
* 不符立即销毁会话并返回 machine_bound_to_other(附绑定者邮箱,便于
* 认领/联系解绑——2026-09-19 用户裁定容许显示)。绑定状态文件损坏
* fail-closed:锁无法判定时不许登录。
*
* 登出≠解绑:logout 只清用户会话槽,机器绑定(H/R)完全不经过这里。
*
* DB-T6 自动绑定(2026-09-19 裁定):登录成功 / 恢复会话后若本机未绑定,
* main 自动发起一次免口令绑定,无需用户点击。结果(pending 确认码 / 失败
* 码)留在 main 内存,binding-status 随时回查;绑定失败绝不阻塞登录。
*/
import { loadMachineBinding, MachineBindingError } from './machine-binding'
import {
canonicalizeRelaySite,
type FetchLike,
relayAccountBindingOwner,
relayAccountDeleteInstallation,
RelayAccountError,
relayAccountFetchMe,
relayAccountListInstallationIds,
relayAccountLogin,
relayAccountLogout,
relayAccountRefresh,
relayAccountRegisterResend,
relayAccountRegisterStart,
relayAccountResetConfirm,
relayAccountResetRequest,
type RelayAccountSession,
relayAccountUpdateProfile
} from './relay-account'
import {
loadRememberedLogin,
loadUserAccount,
persistRememberedLogin,
persistUserAccount,
type StoredUserAccount,
type UserAccountStoreIo
} from './user-account-store'
export const USER_ACCOUNT_CLIENT_ID = 'hermes-desktop'
/** 插件管理面(/api/plugins/mercury-relay/*)失败的结构化形态。 */
export class PluginApiError extends Error {
readonly code: string
readonly status: number
constructor(code: string, status: number) {
super(code)
this.code = code
this.status = status
}
}
export interface UserAccountIpcDeps {
ipcMain: { handle: (channel: string, fn: (event: any, payload?: any) => Promise<any>) => void }
io: UserAccountStoreIo
fetcher: FetchLike
/** 插件 state.json 原文;ENOENT 抛错视为未绑定。 */
readMachineBindingState: () => string
/**
* 调本机 gateway 的插件管理 API(路径如 'identity/login')。
* 非 2xx 必须抛 PluginApiError(detail 即 code);gateway 不可达抛
* PluginApiError('plugin_unreachable', 0)。method 默认 POST;GET 时
* body 被忽略(如 'status' 查机器码)。
*/
callPluginApi: (path: string, body: Record<string, unknown>, method?: 'GET' | 'POST') => Promise<any>
/** 绑定时的 suggested_name(本机显示名)。 */
machineName: string
}
/** 预期内的失败走结构化返回(renderer 按 code 上文案);意外仍抛。 */
function failure(error: unknown): { ok: false; code: string } {
if (error instanceof RelayAccountError || error instanceof PluginApiError) {
return { ok: false, code: error.code }
}
throw error
}
export function registerUserAccountIpc(deps: UserAccountIpcDeps): void {
const { ipcMain, io, fetcher, readMachineBindingState, callPluginApi, machineName } = deps
let cached: StoredUserAccount | null = null
/**
* 最近一次绑定尝试的结果(DB-T6 自动绑定):pending 确认码 / 失败码,
* 供 binding-status 随时回查。只在 main 内存里,随进程退出消失
* (pending 码本就 10 分钟 TTL,重启后重试即可)。
*/
let lastBind: { pending?: { code: string; expiresAt: string }; error?: string } | null = null
let autoBindAttempted = false
function current(): StoredUserAccount | null {
if (cached) {return cached}
cached = loadUserAccount(io)
return cached
}
function store(account: StoredUserAccount | null): void {
cached = account
persistUserAccount(account, io)
}
/**
* 401 → refresh 一次重试;refresh 也被拒 = 服务端判定的硬终点,往上报
* session_expired,但**不清本地会话**——离线时它仍是放行的凭证(2026-09-24
* 用户裁定"离线优先")。真正的清零只发生在 logout / unbind。
*/
async function withSession<T>(fn: (session: RelayAccountSession) => Promise<T>): Promise<T> {
const account = current()
if (!account) {throw new RelayAccountError('not_logged_in', 0)}
try {
return await fn(account.session)
} catch (error) {
if (!(error instanceof RelayAccountError) || error.status !== 401) {throw error}
}
let refreshed: RelayAccountSession
try {
refreshed = await relayAccountRefresh(account.session, fetcher)
} catch (error) {
if (error instanceof RelayAccountError && error.status === 401) {
throw new RelayAccountError('session_expired', 401)
}
throw error
}
store({ ...account, session: refreshed })
return fn(refreshed)
}
/**
* U-5:登录成功后当场校验。返回 null 放行;否则结构化失败(会话已销毁)。
* 绑定状态损坏 = 锁无法判定,fail-closed 拒绝登录(同样销毁新会话)。
*/
async function enforceMachineLock(session: RelayAccountSession): Promise<Record<string, unknown> | null> {
let binding
try {
binding = loadMachineBinding(readMachineBindingState)
} catch (error) {
if (error instanceof MachineBindingError) {
await relayAccountLogout(session, fetcher)
return { ok: false, code: 'binding_state_invalid' }
}
throw error
}
if (!binding) {return null}
if (binding.site === session.site) {
const owned = await relayAccountListInstallationIds(session.site, session.accessToken, fetcher)
if (owned.includes(binding.installationId)) {return null}
}
// 他账号/他站:当场销毁会话(U-5),再尽力取绑定者邮箱供锁态提示。
const binderEmail = await relayAccountBindingOwner(session.site, session.accessToken, binding.installationId, fetcher).catch(
() => null
)
await relayAccountLogout(session, fetcher)
return { ok: false, code: 'machine_bound_to_other', binderEmail }
}
/**
* 断网回落:本地校验记住的凭据,命中即放行(离线优先,2026-09-24 用户裁定)。
* 前提是"这台机器上登录过这个账号"——记住槽只在登录成功时写、只在校验失败时
* 清(unbind 全清),所以这里放行的账号,其会话与锁定在创建时都过了服务端。
*
* 不新增暴露面:口令本来就以 safeStorage 加密存在本地供表单预填,这里只是把
* 它当凭据用。能离线做的 U-5 复核照做(绑定状态损坏 fail-closed、绑定的站
* 不符即拒);安装归属要联网查,离线判不了。
*/
async function offlineLogin(site: string, email: string, password: string) {
// 没有会话就没有可解锁的东西:离线登入只把盘上已有的会话放回来,不新建。
const account = current()
if (!account) {return { ok: false, code: 'network' }}
let binding
try {
binding = loadMachineBinding(readMachineBindingState)
} catch (error) {
if (error instanceof MachineBindingError) {return { ok: false, code: 'binding_state_invalid' }}
throw error
}
if (binding && binding.site !== site) {
return { ok: false, code: 'machine_bound_to_other', binderEmail: null }
}
const remembered = loadRememberedLogin(io)
// 本地没有这个账号的凭据:离线无解,维持 network 文案等用户联网。
if (!remembered || remembered.site !== site || remembered.email !== email) {
return { ok: false, code: 'network' }
}
if (remembered.password !== password) {
return { ok: false, code: 'invalid_credentials' }
}
// 会话已在盘上(含被服务端判过期的):用缓存 profile 恢复登录态,不落盘。
return { ok: true, site, profile: account.profile, offline: true }
}
ipcMain.handle('hermes:account:login', async (_event, payload) => {
let site: string
try {
site = canonicalizeRelaySite(String(payload?.site ?? ''))
} catch (error) {
return failure(error)
}
const email = String(payload?.email ?? '').trim()
const password = String(payload?.password ?? '')
if (!email || !password) {
return { ok: false, code: 'invalid_login_request' }
}
try {
const session = await relayAccountLogin(site, email, password, USER_ACCOUNT_CLIENT_ID, fetcher)
const locked = await enforceMachineLock(session)
if (locked) {return locked}
const profile = await relayAccountFetchMe(site, session.accessToken, fetcher)
store({ session, profile, profileAsOf: new Date().toISOString() })
// 记住凭据:登录成功即刷新 remembered 槽(登出不清,下次启动预填)。
persistRememberedLogin({ site, email, password }, io)
// DB-T6:登录成功即自动绑定本机(未绑定时);失败不阻塞登录。
await autoBind()
return { ok: true, site, profile }
} catch (error) {
if (error instanceof RelayAccountError && error.code === 'network') {
return offlineLogin(site, email, password)
}
return failure(error)
}
})
ipcMain.handle('hermes:account:status', async () => {
const account = current()
if (!account) {return { loggedIn: false }}
try {
const profile = await withSession(session => relayAccountFetchMe(session.site, session.accessToken, fetcher))
store({ ...current()!, profile, profileAsOf: new Date().toISOString() })
// DB-T6:恢复出的会话若尚未尝试过自动绑定(如进程重启后),补一次。
if (!autoBindAttempted) {await autoBind()}
return { loggedIn: true, site: account.session.site, offline: false, profile }
} catch (error) {
if (error instanceof RelayAccountError && error.code === 'session_expired') {
return { loggedIn: false, sessionExpired: true }
}
if (error instanceof RelayAccountError && error.code === 'network') {
// U-3:relay 不可达但有本地会话 → 标离线进入,展示缓存数据 + 数据时间。
return {
loggedIn: true,
site: account.session.site,
offline: true,
profile: account.profile,
profileAsOf: account.profileAsOf
}
}
throw error
}
})
ipcMain.handle('hermes:account:me', async () => {
const profile = await withSession(session => relayAccountFetchMe(session.site, session.accessToken, fetcher))
store({ ...current()!, profile, profileAsOf: new Date().toISOString() })
return { profile }
})
ipcMain.handle('hermes:account:update-profile', async (_event, payload) => {
// 只放行服务端 PROFILE_FIELDS 同口径三字段;null = 清空。其余键(含
// email)一律丢弃,绝不转发——renderer 按码上文案,不需要 email_not_editable。
const patch: Record<string, string | null> = {}
for (const field of ['username', 'nickname', 'phone'] as const) {
const value = payload?.[field]
if (value === null) {patch[field] = null}
if (typeof value === 'string') {patch[field] = value}
}
if (Object.keys(patch).length === 0) {
return { ok: false, code: 'nothing_to_update' }
}
try {
const updated = await withSession(session => relayAccountUpdateProfile(session.site, session.accessToken, patch, fetcher))
const account = current()!
const profile = { ...account.profile, ...updated }
store({ ...account, profile, profileAsOf: new Date().toISOString() })
return { ok: true, profile }
} catch (error) {
return failure(error)
}
})
ipcMain.handle('hermes:account:binding-status', async () => {
const machineId = await queryMachineId()
let binding
try {
binding = loadMachineBinding(readMachineBindingState)
} catch (error) {
if (error instanceof MachineBindingError) {
return { bound: false, stateInvalid: true, machineId, pending: null, bindError: lastBind?.error ?? null }
}
throw error
}
if (!binding) {
return { bound: false, machineId, pending: lastBind?.pending ?? null, bindError: lastBind?.error ?? null }
}
return { bound: true, site: binding.site, installationId: binding.installationId, machineId }
})
/**
* U-1/RL-A1 免口令绑定主体:用当前用户会话 access token 让本机插件完成
* 绑定(口令永远不再经手)。插件 401(会话失效)并入 withSession 的
* 刷新重试链。binding_pending(双因素)原样透传给 UI。
*/
async function performBind(): Promise<
| { ok: true; state: 'bound' }
| { ok: true; state: 'binding_pending'; code: string; expiresAt: string }
| { ok: false; code: string }
> {
let existing
try {
existing = loadMachineBinding(readMachineBindingState)
} catch (error) {
if (error instanceof MachineBindingError) {return { ok: false, code: 'binding_state_invalid' }}
throw error
}
if (existing) {return { ok: false, code: 'already_bound' }}
try {
const result = await withSession(async session => {
try {
return await callPluginApi('identity/login', {
site: session.site,
session_token: session.accessToken,
suggested_name: machineName
})
} catch (error) {
if (error instanceof PluginApiError && error.status === 401) {
throw new RelayAccountError(error.code, 401)
}
throw error
}
})
if (result?.state === 'binding_pending') {
return {
ok: true,
state: 'binding_pending',
code: typeof result.code === 'string' ? result.code : '',
expiresAt: typeof result.expires_at === 'string' ? result.expires_at : ''
}
}
if (result?.state !== 'bound') {return { ok: false, code: 'server' }}
return { ok: true, state: 'bound' }
} catch (error) {
return failure(error)
}
}
function recordBind(result: Awaited<ReturnType<typeof performBind>>): void {
if (result.ok === false) {
lastBind = result.code === 'already_bound' ? null : { error: result.code }
return
}
if (result.state === 'bound') {
lastBind = null
return
}
lastBind = { pending: { code: result.code, expiresAt: result.expiresAt } }
}
/**
* DB-T6 自动绑定:登录成功 / 恢复会话后若本机未绑定则自动发起一次。
* 绝不阻塞或逆转登录——任何意外都只落成 lastBind.error 供界面展示。
*/
async function autoBind(): Promise<void> {
autoBindAttempted = true
try {
recordBind(await performBind())
} catch {
lastBind = { error: 'server' }
}
}
/** 机器码(relay allowlist 展示口径):插件不可达时回 null,不影响状态查询。 */
async function queryMachineId(): Promise<string | null> {
try {
const status = await callPluginApi('status', {}, 'GET')
return typeof status?.relay_machine_id === 'string' ? status.relay_machine_id : null
} catch {
return null
}
}
ipcMain.handle('hermes:account:bind-machine', async () => {
try {
const result = await performBind()
recordBind(result)
return result
} catch (error) {
return failure(error)
}
})
/**
* U-6 解绑 = 全清:服务端撤销 installation(尽力,404 幂等;会话死/
* 网络断不阻塞)→ 插件 purge(清绑定含 R + 抹 H,机器回裸态)→
* 删除所有会话(本地用户槽清零 + 尽力服务端吊销)。插件不可达时
* 结构化失败、会话保留,用户可重试——不留下"半清"的确定态假象。
*/
ipcMain.handle('hermes:account:unbind-machine', async () => {
let binding
try {
binding = loadMachineBinding(readMachineBindingState)
} catch (error) {
if (error instanceof MachineBindingError) {return { ok: false, code: 'binding_state_invalid' }}
throw error
}
if (!binding) {return { ok: false, code: 'not_bound' }}
try {
await withSession(session => relayAccountDeleteInstallation(session.site, session.accessToken, binding.installationId, fetcher))
} catch {
// 尽力撤销:本地清零照旧
}
try {
await callPluginApi('identity/purge', {})
} catch (error) {
return failure(error)
}
const account = current()
if (account) {
await relayAccountLogout(account.session, fetcher)
store(null)
}
// U-6 全清含记住的凭据——机器回裸态后登录页不再预填本机旧账号。
persistRememberedLogin(null, io)
lastBind = null
autoBindAttempted = false
return { ok: true }
})
ipcMain.handle('hermes:account:logout', async () => {
const account = current()
if (account) {
await relayAccountLogout(account.session, fetcher)
store(null)
}
lastBind = null
autoBindAttempted = false
return { ok: true }
})
ipcMain.handle('hermes:account:register-start', async (_event, payload) => {
try {
const site = canonicalizeRelaySite(String(payload?.site ?? ''))
await relayAccountRegisterStart(site, String(payload?.email ?? '').trim(), String(payload?.password ?? ''), fetcher)
return { ok: true }
} catch (error) {
return failure(error)
}
})
ipcMain.handle('hermes:account:register-resend', async (_event, payload) => {
try {
const site = canonicalizeRelaySite(String(payload?.site ?? ''))
await relayAccountRegisterResend(site, String(payload?.email ?? '').trim(), fetcher)
return { ok: true }
} catch (error) {
return failure(error)
}
})
ipcMain.handle('hermes:account:reset-request', async (_event, payload) => {
try {
const site = canonicalizeRelaySite(String(payload?.site ?? ''))
await relayAccountResetRequest(site, String(payload?.email ?? '').trim(), fetcher)
return { ok: true }
} catch (error) {
return failure(error)
}
})
ipcMain.handle('hermes:account:reset-confirm', async (_event, payload) => {
try {
const site = canonicalizeRelaySite(String(payload?.site ?? ''))
const email = String(payload?.email ?? '').trim()
await relayAccountResetConfirm(
site,
email,
String(payload?.code ?? '').trim(),
String(payload?.password ?? ''),
fetcher
)
// A successful reset invalidates the last successful login's password.
const remembered = loadRememberedLogin(io)
if (remembered?.site === site && remembered.email.toLowerCase() === email.toLowerCase()) {
persistRememberedLogin(null, io)
}
return { ok: true }
} catch (error) {
return failure(error)
}
})
/**
* 登录表单预填:返回最近一次成功登录记住的 {site, email, password}。
* login 成功写入;reset 成功及 unbind 清除旧凭据——renderer 无写口。
*/
ipcMain.handle('hermes:account:remembered:load', async () => {
return loadRememberedLogin(io)
})
}