Compare commits

...
Author SHA1 Message Date
weichen 482ba885e1 Merge remote-tracking branch 'origin/main' into feature/login-clear-stale-key 2026-10-11 14:04:26 +00:00
weichen 09310cb753 Clear stale API-key credentials when a password session begins
Why: the auth middleware prefers Bearer keys over the session cookie, so a stale key left in localStorage shadows valid cookie sessions and every request after password login returns 401 (observed at http://192.168.3.2:8788 on 2026-10-11).
Evidence: modules/frontend vitest 19 files / 88 tests green; auth.test.ts covers beginSession clearing the stored Bearer before any fetch.
Impact: password logins switch to the HttpOnly cookie session cleanly; API-key logins unchanged.
Refs: task card feature-login-clear-stale-key in Agent-System-Dev.
2026-10-11 13:53:27 +00:00
weichen fcf40ff75f Merge pull request 'Keep API-key holders out of the dead-end change-password gate' (#54) from feature/admin-auth-gate-fix into main 2026-10-11 13:52:15 +00:00
weichen dc18d6c747 Keep API-key holders out of the dead-end change-password gate
Why: entering the forced-change gate with an API-key credential always
fails 401 (passwords are session-owned), and the gate mapped every 401
to "Incorrect username or password", masking the real reason and hiding
the self-service exit; admins got locked out before reaching user
management.

Evidence: npx vitest run green (20 files / 93 tests incl. new
test/loginpage.test.ts); tsc -b and check-knip green.

Impact: App boot routes key+mustChange to the login page (key cleared);
the API-key tab advises password login for must-change accounts; the
gate maps backend error strings to distinct actionable messages; api.me
type gains authVia.

Refs: Dev task feature/admin-auth-gate-fix.
2026-10-11 13:45:56 +00:00
weichen 68f6c8638b Merge pull request 'Render saved surfaces in the preview pane with refresh recovery' (#53) from feature/generative-ui-u4 into main 2026-10-11 11:40:00 +00:00
weichen df4ca1e470 Render saved surfaces in the preview pane with refresh recovery
Why: U4 read-only experience - surface.updated events project into a
surfaces slice with a strict revision guard (stale/duplicate events
never overwrite; server snapshot is the single owner), preview tabs
gain a surface: kind that renders SurfaceView (doc-refs bridge into
artifact previews), a new page auto-opens a tab without stealing the
user's active non-surface tab, and opening a conversation reloads the
authoritative surface list so refresh/断线 restores pages.

Evidence: tsc clean; vitest 87/87 including new surface projection
tests (stale/duplicate/multi-surface); UI SurfaceView suite green in
its repo.

Impact: Frontend-only; no backend or contract changes.

Refs: Core PRs #27/#28; UI PRs #42/#43/#44; Dev task
feature/generative-ui-u4
2026-10-11 11:39:57 +00:00
weichen 8108655e61 Merge pull request 'Sync the contracts mirror from the core authority' (#52) from feature/generative-ui-u2-contract-sync into main 2026-10-11 08:45:23 +00:00
weichen 6b4920b509 Sync the contracts mirror from the core authority
Why: Core PR #26 added the node id/source fields to ts/types.ts (the
single authority); the mirror is regenerated so the sync header and
content match make contracts output exactly (manual edits drift).

Evidence: File generated by the make contracts copy steps; combined
verification in the Dev workspace after re-pinning.

Impact: Header banner plus the already-reviewed id/source fields.

Refs: Core PR #26; Dev task feature/generative-ui-u2
2026-10-11 08:45:13 +00:00
weichen a26d7c5aee Merge pull request 'Mirror the optional node id/source fields in the contract' (#51) from feature/generative-ui-u2-nodeids into main 2026-10-11 08:16:11 +00:00
weichen 69b1487fb7 Mirror the optional node id/source fields in the frontend contract
Why: Core PR #25 adds optional id/source to UINode for the Generative
UI Surface layer; the TypeScript contract authority mirrors core types.

Evidence: Type-only additive change; combined verification runs in the
Dev workspace make test.

Impact: No component or behavior change.

Refs: Agent-System-Core #25; Dev task feature/generative-ui-u2
2026-10-11 08:15:44 +00:00
weichen 32e327a831 Merge pull request 'Three-column workbench layout rework (Work/Files third column)' (#50) from feature/ui-three-column-workbench into main 2026-10-11 03:06:44 +00:00
10 changed files with 258 additions and 12 deletions
+6 -1
View File
@@ -52,8 +52,13 @@ export function App() {
const r = await auth.fetch('/api/health')
if (r.status === 401) { setAuthState('login'); return }
// 已持有有效会话 cookie(或密钥)时直接放行;会话携带 must_change 时先进改密门。
// 改密接口只认会话登录:API key 凭证进改密门是死路(提交必 401),
// key 持有者清 key 回登录页,改走密码登录完成改密。
const me = await api.me().catch(() => null)
if (me?.mustChangePassword) { setMustChangeOwner(me.owner); setAuthState('mustchange'); return }
if (me?.mustChangePassword) {
if (me.authVia === 'key') { auth.clear(); setAuthState('login'); return }
setMustChangeOwner(me.owner); setAuthState('mustchange'); return
}
setAuthState('ok')
} catch {
setAuthState('ok') // 网络异常不是认证问题;放行交给业务层报错
+4 -2
View File
@@ -30,7 +30,9 @@ export const api = {
setPermissionMode: (id: string, permissionMode: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ permissionMode }) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }),
checkpoints: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/checkpoints?messageId=${encodeURIComponent(messageId)}`).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)),
rewindFiles: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/rewind`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messageId }) }).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)),
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean }>(r)),
surfaceList: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/surfaces`).then(r => json<any[]>(r)),
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<any>(r)),
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; isAdmin?: boolean; mustChangePassword?: boolean }>(r)),
// ---- 认证与会话(管理员发放制,R2)----
login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => {
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
@@ -160,7 +162,7 @@ const streamEventListeners: Record<StreamEventType, true> = {
'card.created': true,
'card.updated': true,
'card.status': true,
'card.resolved': true,
'card.resolved': true, 'surface.updated': true,
}
export function connectEvents(conversationId: string, since: number, handlers: SSEHandlers, projectId?: string): { close: () => void } {
+3
View File
@@ -22,6 +22,9 @@ export const auth = {
apiKey(): string { return storage.getItem(KEY) ?? '' },
setKey(key: string) { storage.setItem(KEY, key.trim()) },
clear() { storage.removeItem(KEY) },
// 密码登录成功后切换到 HttpOnly 会话 cookie:必须清掉残留 Bearer——服务端
// 认证中间件 Bearer 优先于会话 cookie,失效残留 key 会把有效会话也判成 401。
beginSession() { this.clear() },
// 带凭证的 fetch:未设置密钥时与原生 fetch 等价(嵌入式档无认证)。
fetch(input: string, init?: RequestInit): Promise<Response> {
const key = this.apiKey()
+28 -3
View File
@@ -41,6 +41,8 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
setErr(''); setBusy(true)
try {
const me = await api.login(owner.trim(), password)
// 会话凭证是 HttpOnly cookie;清残留 Bearer,避免其遮蔽有效会话。
auth.beginSession()
if (me.mustChangePassword) {
// 初始/重置密码:先完成强制改密再放行。
onMustChange(me.owner)
@@ -57,7 +59,17 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
auth.setKey(key)
try {
const r = await auth.fetch('/api/health')
if (r.ok) { onAuthed(); return }
if (r.ok) {
// 账号处于初始/重置密码状态时,key 完成不了强制改密(改密只认会话登录):
// 不放行进死路改密门,引导改走账号密码登录。
const me = await api.me().catch(() => null)
if (me?.mustChangePassword) {
auth.clear()
setErr('该账号使用初始/重置密码,请先用账号密码登录完成改密')
return
}
onAuthed(); return
}
throw new Error(String(r.status))
} catch {
auth.clear()
@@ -118,7 +130,7 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
await api.changePassword(current, next)
onDone()
} catch (e) {
setErr(humanLoginError(String(e)).replace('401: ', ''))
setErr(humanChangePasswordError(String(e)))
} finally { setBusy(false) }
}
@@ -145,10 +157,23 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
)
}
/** 错误文案:把 HTTP 状态码翻成人话。 */
/** 登录错误文案:把 HTTP 状态码翻成人话。 */
export function humanLoginError(raw: string): string {
if (raw.includes('401')) return '用户名或密码不正确'
if (raw.includes('429')) return '失败次数过多,请稍后再试'
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
return raw.replace(/^\d{3}:\s*/, '')
}
/** 改密门错误文案:改密的 401 有多种真实原因(当前密码错/无会话/会话过期/失效 key),
* 不能沿用登录的「用户名或密码不正确」一刀切——那会把可自愈的凭证问题伪装成密码错误。
* 后端文案是现行 HTTP 契约的一部分,这里按其区分并用测试锁定。 */
export function humanChangePasswordError(raw: string): string {
if (raw.includes('current password incorrect')) return '当前密码不正确'
if (raw.includes('session login required')) return '请先用账号密码登录再改密(API 密钥不能修改密码)'
if (raw.includes('session expired')) return '会话已过期,请重新登录'
if (raw.includes('invalid or revoked API key')) return '本地残留的 API 密钥已失效,请退出登录后重试'
if (raw.includes('429')) return '失败次数过多,请稍后再试'
if (raw.includes('401')) return '认证失败,请重新登录'
return raw.replace(/^\d{3}:\s*/, '')
}
+31 -1
View File
@@ -1,7 +1,7 @@
// 第三栏「文件」入口的多标签预览:打开/切换/关闭标签、编辑保存、展开铺满与下载。
// 列表 ⇄ 预览在同一入口内切换;宽度档(阅读档 420–1100)由 App 的分隔条控制。
import { useEffect, useState } from 'react'
import { Btn, FilePreview, Icon, TabStrip } from '@ui'
import { Btn, FilePreview, Icon, SurfaceView, TabStrip } from '@ui'
import { api } from '../api'
import type { Artifact } from '../contracts'
import { humanSize } from '../messageparts'
@@ -15,6 +15,10 @@ export function PreviewPane() {
const artifacts = useStore(s => s.artifacts)
const expanded = useStore(s => s.ui.previewExpanded)
const active = tabs.find(t => t.id === activeId) ?? tabs[0]
const surfaces = useStore(s => s.surfaces)
const surfaceSnap = active?.id.startsWith('surface:')
? surfaces.find(x => 'surface:' + String(x.identity.surfaceId) === active.id)
: undefined
const art = active ? artifacts.find(a => a.id === active.id) : undefined
const textKind = !!art && isTextKind(art)
const [content, setContent] = useState<string | null>(null)
@@ -35,6 +39,32 @@ export function PreviewPane() {
}, [art?.id])
if (!active) return null
if (active.id.startsWith('surface:')) {
return (
<div className="preview-pane">
<TabStrip
tabs={tabs.map(t => ({ id: t.id, label: t.name }))}
activeId={active.id}
onActivate={id => store.activatePreview(id)}
onClose={id => store.closePreview(id)}
/>
<div className="preview-pane-bar">
<button type="button" className="icon-btn" title="返回文件列表" onClick={() => store.setUI({ filesView: 'list', previewExpanded: false })}>
<Icon name="list" size={14} />
</button>
<span className="preview-pane-name" title={active.name}>{active.name}</span>
</div>
<div className="preview-pane-body">
{surfaceSnap
? <SurfaceView snapshot={surfaceSnap} docBridge={{ onOpenDoc: docId => {
const art = artifacts.find(a => a.id === docId)
if (art) store.requestPreview(art)
} }} />
: <p className="surface-view-empty">页面快照不可用(可能已被移除或无权限)。</p>}
</div>
</div>
)
}
return (
<div className="preview-pane">
<TabStrip
+47 -1
View File
@@ -99,7 +99,8 @@ export type StreamEventType =
| 'agent.spawn' | 'agent.progress' | 'agent.result'
| 'artifact.created' | 'artifact.updated'
| 'run.completed' | 'run.failed' | 'error'
| 'card.created' | 'card.updated' | 'card.status' | 'card.resolved';
| 'card.created' | 'card.updated' | 'card.status' | 'card.resolved'
| 'surface.updated';
export interface StreamEvent<P = unknown> {
seq: number; runId?: ID; conversationId: ID; ts: string; v: number;
@@ -185,6 +186,9 @@ export type UINodeType =
export interface UINode {
type: UINodeType;
/** Surface 层节点级标注(Generative UI U2):整页唯一稳定 id 与事实来源标识,旧路径不填。 */
id?: string;
source?: string[];
title?: string;
items?: string[];
kv?: [string, string][];
@@ -352,3 +356,45 @@ export interface UploadInfo {
artifactId: ID; filename: string; mime?: string; size: number;
parsed?: ParsedDocument;
}
// ---- Generative UI Surface(提案 U2/U4;与 Go surface.go 同步)----
export interface SurfaceCompat {
schemaVersion: number;
catalogVersion: string;
pluginVersion?: string;
}
export interface SurfaceIdentity {
surfaceId: ID;
owner: string;
projectId?: ID;
conversationId?: ID;
sourceRunId?: ID;
pluginId: string;
}
export interface SurfaceSpec {
title: string;
nodes: UINode[];
}
export interface SurfaceSnapshot {
identity: SurfaceIdentity;
compat: SurfaceCompat;
revision: number;
baseRevision?: number;
contextVersion?: string;
activationEpoch: number;
spec: SurfaceSpec;
semanticText?: string;
}
export interface SurfaceMeta {
surfaceId: ID;
title: string;
pluginId: string;
revision: number;
conversationId?: ID;
updatedAt: string;
}
+57 -4
View File
@@ -16,8 +16,19 @@ export function isFontSizeChoice(value: number): value is FontSizeChoice {
return (FONT_SIZE_CHOICES as readonly number[]).includes(value)
}
import type {
Project, Conversation, Message, Run, Artifact, HITLCard, PlanSnapshot, TodoList,
AgentProfile, StreamEvent, PluginPanel, ActivityCard,
ActivityCard,
AgentProfile,
Artifact,
Conversation,
HITLCard,
Message,
PlanSnapshot,
PluginPanel,
Project,
Run,
StreamEvent,
SurfaceSnapshot,
TodoList
} from './contracts'
export interface ContextObs {
@@ -86,6 +97,8 @@ interface AppState {
plan: PlanSnapshot | null
todos: TodoList | null
artifacts: Artifact[]
/** generative-ui:会话内已保存页面(surface.updated 投影;按 surfaceId 存最新修订)。 */
surfaces: SurfaceSnapshot[]
cards: ActivityCard[]
projectCards: ActivityCard[]
inboxScope: 'conv' | 'project'
@@ -153,7 +166,7 @@ let state: AppState = {
contextObs: {},
projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
plan: null, todos: null, artifacts: [], cards: [], projectCards: [], inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
plan: null, todos: null, artifacts: [], surfaces: [], cards: [], projectCards: [], inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
serverWorkspaces: [],
ui: loadUI(), connected: false, posting: false, error: null, toast: null,
outbox: [], outboxDraining: false,
@@ -359,7 +372,7 @@ export const store = {
async openConversation(convId: string) {
if (sse) { sse.close(); sse = null }
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], surfaces: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
persistConvId(convId)
// 空态时打开的预览标签('_' 键)随会话进入一起迁移,不留在不可见的孤儿键下。
if ((state.previewTabs['_'] ?? []).length > 0) {
@@ -369,6 +382,8 @@ export const store = {
previewActive: { ...state.previewActive, [convId]: active },
})
}
// U4/R5:已保存页面从权威快照重载(刷新/断线恢复;事件流只做增量投影)。
api.surfaceList(convId).then(list => { if (state.currentConvId === convId) set({ surfaces: list ?? [] }) }).catch(() => {})
const resumeSeq = await store.reconcile()
store.connectLive(resumeSeq ?? state.lastSeq)
},
@@ -490,6 +505,33 @@ export const store = {
set({ todos: p.list })
break
}
case 'surface.updated': {
// KTD8:单一状态所有者——服务端快照为权威,本地按 revision 守卫:
// 旧修订/重复事件不覆盖新修订;切会话由 selectConversation 清空。
const p = e.payload as SurfaceSnapshot
if (!p?.identity?.surfaceId) break
const id = String(p.identity.surfaceId)
const surfaces = state.surfaces.filter(x => String(x.identity.surfaceId) !== id)
const existing = state.surfaces.find(x => String(x.identity.surfaceId) === id)
if (!existing || p.revision > existing.revision) {
surfaces.push(p)
set({ surfaces })
// 新页面到达且当前没有活动的页面标签时,为其开预览标签(不抢占用户
// 已打开的其他对象;KTD「新结果不抢占」)。
const conv = previewTabsKey(state.currentConvId)
const tabs = state.previewTabs[conv] ?? []
const activeId = state.previewActive[conv] ?? null
const hasSurfaceTab = tabs.some(t => t.id.startsWith('surface:'))
if (!hasSurfaceTab || (activeId ?? '').startsWith('surface:')) {
const opened = openPreviewTab(tabs, { id: 'surface:' + id, name: p.spec?.title || '页面' })
set({
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
previewActive: { ...state.previewActive, [conv]: opened.activeId },
})
}
}
break
}
case 'card.created':
case 'card.updated': {
const p = e.payload as { card: ActivityCard }
@@ -846,6 +888,17 @@ export const store = {
})
syncFilesEntry(true)
},
openSurface(surfaceId: string) {
// generative-ui:页面进预览第三栏(标签 id 带 surface: 前缀区分工件)。
const snap = state.surfaces.find(x => String(x.identity.surfaceId) === surfaceId)
const conv = previewTabsKey(state.currentConvId)
const opened = openPreviewTab(state.previewTabs[conv] ?? [], { id: 'surface:' + surfaceId, name: snap?.spec?.title || '页面' })
set({
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
previewActive: { ...state.previewActive, [conv]: opened.activeId },
})
syncFilesEntry(true)
},
togglePreviewExpanded() { setUI({ previewExpanded: !state.ui.previewExpanded }) },
activatePreview(id: string) {
const conv = previewTabsKey(state.currentConvId)
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { store } from './store'
import type { StreamEvent, SurfaceSnapshot } from './contracts'
// U4/R5:surface.updated 投影的 revision 守卫——旧修订/重复事件不得覆盖新修订;
// 服务端快照是唯一权威(KTD8 单一状态所有者)。
function surfEvent(seq: number, surfaceId: string, revision: number, title: string): StreamEvent<SurfaceSnapshot> {
return {
seq, conversationId: 'c1', ts: new Date().toISOString(), v: 1, type: 'surface.updated',
payload: {
identity: { surfaceId, owner: 'alice', pluginId: 'generative-ui', conversationId: 'c1' },
compat: { schemaVersion: 1, catalogVersion: 'ui-v1' },
revision, activationEpoch: 1,
spec: { title, nodes: [{ type: 'markdown', id: 'n1', markdown: 'x' }] },
},
}
}
describe('surface.updated projection', () => {
it('新事件入库;旧修订与重复事件不覆盖', () => {
store.applyEvent(surfEvent(1, 's1', 1, '初版'))
store.applyEvent(surfEvent(2, 's1', 2, '修订二'))
store.applyEvent(surfEvent(3, 's1', 1, '迟到的旧修订'))
store.applyEvent(surfEvent(4, 's1', 2, '重复事件'))
const list = store.getState().surfaces
expect(list).toHaveLength(1)
expect(list[0].revision).toBe(2)
expect(list[0].spec.title).toBe('修订二')
})
it('多页面并存,互不覆盖', () => {
store.applyEvent(surfEvent(5, 's2', 1, '页面二'))
const list = store.getState().surfaces
expect(list.filter(x => String(x.identity.surfaceId) === 's1')).toHaveLength(1)
expect(list.filter(x => String(x.identity.surfaceId) === 's2')).toHaveLength(1)
})
})
+11
View File
@@ -40,4 +40,15 @@ describe('auth 凭证模块', () => {
expect(auth.apiKey()).toBe('')
expect(auth.withKey('/x')).toBe('/x')
})
it('beginSession:密码会话生效后清残留 Bearer,fetch 不再带头', async () => {
auth.setKey('ask_stale')
auth.beginSession()
expect(auth.apiKey()).toBe('')
const spy = vi.fn().mockResolvedValue(new Response('{}'))
vi.stubGlobal('fetch', spy)
await auth.fetch('/api/me')
const [, init] = spy.mock.calls[0] as [string, RequestInit | undefined]
expect(new Headers(init?.headers ?? {}).get('Authorization')).toBeNull()
})
})
+32
View File
@@ -0,0 +1,32 @@
// 登录/改密门错误文案:登录与改密的 401 语义不同,映射分开锁定
// (后端错误串是现行 HTTP 契约的一部分,这里逐字对拍防漂移)。
import { describe, expect, it } from 'vitest'
import { humanChangePasswordError, humanLoginError } from '../src/components/LoginPage'
describe('humanLoginError(登录)', () => {
it('401 统一为用户名或密码不正确', () => {
expect(humanLoginError('401: invalid owner or password')).toBe('用户名或密码不正确')
})
it('429/412 各有文案,其余剥状态码前缀', () => {
expect(humanLoginError('429: too many failed attempts; retry later')).toBe('失败次数过多,请稍后再试')
expect(humanLoginError('412: auth disabled (embedded single-tenant mode)')).toBe('本服务未开启登录(嵌入式单租户档)')
expect(humanLoginError('400: bad')).toBe('bad')
})
})
describe('humanChangePasswordError(改密门)', () => {
it('当前密码错误不再伪装成登录失败', () => {
expect(humanChangePasswordError('401: current password incorrect')).toBe('当前密码不正确')
})
it('无会话(API key 凭证)给出可行动指引', () => {
expect(humanChangePasswordError('401: session login required (API keys do not own passwords)')).toBe('请先用账号密码登录再改密(API 密钥不能修改密码)')
})
it('会话过期与失效 key 各有文案', () => {
expect(humanChangePasswordError('401: session expired')).toBe('会话已过期,请重新登录')
expect(humanChangePasswordError('401: unauthorized: invalid or revoked API key')).toBe('本地残留的 API 密钥已失效,请退出登录后重试')
})
it('未知 401 回落通用文案,其余错误剥前缀透传', () => {
expect(humanChangePasswordError('401: whatever')).toBe('认证失败,请重新登录')
expect(humanChangePasswordError('400: password must be 8-256 characters')).toBe('password must be 8-256 characters')
})
})