agent-loft 0.2.22 → 0.2.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/core/hub.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  getConversation,
19
19
  isExpired,
20
20
  listConversations,
21
+ recordSessionOwner,
21
22
  touchConversation,
22
23
  updateAgentBinding,
23
24
  updateConversation,
@@ -131,6 +132,9 @@ export type ConversationInfo = {
131
132
  canFork?: boolean;
132
133
  };
133
134
 
135
+ /** 普通成员点了会落盘的「总是允许」 */
136
+ export const GLOBAL_ALWAYS_DENIED = "「总是允许」会对所有人的会话生效,只有管理员能点。可以用「允许一次」或「本会话全部允许」。";
137
+
134
138
  const BUFFER_MAX = 400;
135
139
  const CONTEXT_MAX = 500;
136
140
  /** 会占住会话操作队列的 agent 查询,最多等这么久 */
@@ -384,6 +388,8 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
384
388
  channelId: string;
385
389
  defaultAgent: string;
386
390
  title?: string;
391
+ /** 谁的对话(用户 id)。只有 Web 上新建的有 */
392
+ owner?: string;
387
393
  }): Conversation {
388
394
  return ensureConversation({
389
395
  key: params.key,
@@ -391,6 +397,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
391
397
  channelId: params.channelId,
392
398
  agent: params.defaultAgent,
393
399
  ...(params.title ? { title: params.title } : {}),
400
+ ...(params.owner ? { owner: params.owner } : {}),
394
401
  });
395
402
  }
396
403
 
@@ -429,8 +436,10 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
429
436
  };
430
437
  }
431
438
 
432
- list(): ConversationInfo[] {
439
+ /** visible:只列这些(按用户过滤,见 core/users.ts 的 ownedBy) */
440
+ list(visible?: (conv: Conversation) => boolean): ConversationInfo[] {
433
441
  return listConversations()
442
+ .filter((c) => !visible || visible(c))
434
443
  .map((c) => this.info(c.key)!)
435
444
  .sort((a, b) => b.activeAt - a.activeAt);
436
445
  }
@@ -546,6 +555,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
546
555
  // 分叉中还报的是原会话 id:分叉尚未生效,什么都不改
547
556
  if (forkFrom && sessionId === forkFrom) return;
548
557
  updateAgentBinding(key, agent, { sessionId, forkFrom: undefined });
558
+ recordSessionOwner(sessionId, current?.owner);
549
559
  this.emitEvent(key, { type: "meta" });
550
560
  },
551
561
  },
@@ -887,13 +897,21 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
887
897
  return `已恢复会话 ${sessionId},直接接着说就行。${note}`;
888
898
  }
889
899
 
890
- async sessions(key: string, all: boolean): Promise<Extract<CommandReply, { type: "sessions" }>> {
900
+ /** visible:这个用户能看到哪些 agent 会话(多用户时按人过滤)。不给就是全部 */
901
+ async sessions(
902
+ key: string,
903
+ all: boolean,
904
+ visible?: (sessionId: string) => boolean,
905
+ ): Promise<Extract<CommandReply, { type: "sessions" }>> {
891
906
  const conv = getConversation(key)!;
892
907
  const driver = this.requireDriver(conv.agent);
908
+ // 要过滤的话多拿一些,滤完再截到 10 条
909
+ const list = async (cwd: string | undefined) =>
910
+ visible ? (await driver.listSessions(cwd, 100)).filter((s) => visible(s.id)).slice(0, 10) : driver.listSessions(cwd, 10);
893
911
  // 先按当前目录列,一条都没有就自动退回全局 —— 回一句「没有历史」是误导
894
- let sessions = all ? [] : await driver.listSessions(conv.cwd, 10);
912
+ let sessions = all ? [] : await list(conv.cwd);
895
913
  const fellBack = !all && sessions.length === 0;
896
- if (all || fellBack) sessions = await driver.listSessions(undefined, 10);
914
+ if (all || fellBack) sessions = await list(undefined);
897
915
  const current = agentBinding(conv).sessionId;
898
916
  return {
899
917
  type: "sessions",
@@ -1051,7 +1069,8 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
1051
1069
  * 分叉成一条新的 Web 对话:agent 会话复制一份(给了 anchor 就只到那条回复为止),
1052
1070
  * 原对话完全不动。返回新对话的 key。和 /fork 不同 —— /fork 是在同一条对话里换成分叉出来的会话。
1053
1071
  */
1054
- async forkConversation(key: string, anchor?: string): Promise<string> {
1072
+ /** owner:新对话归谁(分叉的人)。不给就沿用原对话的 */
1073
+ async forkConversation(key: string, anchor?: string, owner?: string): Promise<string> {
1055
1074
  const conv = getConversation(key);
1056
1075
  if (!conv) throw new Error(`会话不存在:${key}`);
1057
1076
  const driver = this.requireDriver(conv.agent);
@@ -1062,8 +1081,10 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
1062
1081
  if (!source) throw new Error("这条对话还没有可分叉的会话");
1063
1082
  const sessionId = await withTimeout(driver.forkAt(source, conv.cwd, anchor), "分叉会话");
1064
1083
  copyFooters(source, sessionId);
1084
+ owner ??= conv.owner;
1065
1085
  const newKey = `web:${crypto.randomBytes(6).toString("hex")}`;
1066
- ensureConversation({ key: newKey, channel: "web", channelId: "web", agent: conv.agent });
1086
+ ensureConversation({ key: newKey, channel: "web", channelId: "web", agent: conv.agent, ...(owner ? { owner } : {}) });
1087
+ recordSessionOwner(sessionId, owner);
1067
1088
  updateConversation(newKey, {
1068
1089
  title: `${conv.title ?? "会话"} · 分叉`.slice(0, 80),
1069
1090
  cwd: conv.cwd,
@@ -1119,7 +1140,17 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
1119
1140
  async command(
1120
1141
  key: string,
1121
1142
  cmd: ParsedCommand,
1122
- ctx: { allowedAgents: string[]; by: string; channel?: string },
1143
+ ctx: {
1144
+ allowedAgents: string[];
1145
+ by: string;
1146
+ channel?: string;
1147
+ /** 多用户:发命令的人能看到哪些 agent 会话(/sessions 列哪些、/resume 能恢复哪些) */
1148
+ sessionVisible?: (sessionId: string) => boolean;
1149
+ /** 多用户:/approve 只能处理这些会话里的请求 */
1150
+ convVisible?: (convKey: string) => boolean;
1151
+ /** 多用户:false = 不能点会落盘的「总是允许」(见 InteractionSpec.alwaysGlobal) */
1152
+ allowGlobalAlways?: boolean;
1153
+ },
1123
1154
  ): Promise<CommandReply> {
1124
1155
  const text = (t: string): CommandReply => ({ type: "text", text: t });
1125
1156
  const channel = ctx.channel ?? getConversation(key)?.channel;
@@ -1148,10 +1179,13 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
1148
1179
  case "fork":
1149
1180
  return text(await this.fork(key));
1150
1181
  case "sessions": {
1151
- const reply = await this.sessions(key, cmd.all);
1182
+ const reply = await this.sessions(key, cmd.all, ctx.sessionVisible);
1152
1183
  return reply.sessions.length === 0 ? text("还没有任何历史会话。") : reply;
1153
1184
  }
1154
1185
  case "resume":
1186
+ if (ctx.sessionVisible && !ctx.sessionVisible(cmd.sessionId)) {
1187
+ return text(`找不到会话 ${cmd.sessionId}。\n发 /sessions 看看有哪些可恢复的。`);
1188
+ }
1155
1189
  return text(await this.resume(key, cmd.sessionId));
1156
1190
  case "model":
1157
1191
  return cmd.model ? text(await this.setModel(key, cmd.model)) : await this.models(key);
@@ -1173,6 +1207,13 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
1173
1207
  case "stop":
1174
1208
  return text(await this.stop(key));
1175
1209
  case "approve": {
1210
+ const target = interactions.get(cmd.requestId);
1211
+ if (target && ctx.convVisible && !ctx.convVisible(target.convKey)) {
1212
+ return text(`请求 ${cmd.requestId} 不存在或已处理。`);
1213
+ }
1214
+ if (target?.spec.kind === "approval" && target.spec.alwaysGlobal && cmd.decision === "allow-always" && ctx.allowGlobalAlways === false) {
1215
+ return text(GLOBAL_ALWAYS_DENIED);
1216
+ }
1176
1217
  const settled = interactions.decide(cmd.requestId, cmd.decision as ApprovalDecision, ctx.by);
1177
1218
  if (!settled && interactions.isPending(cmd.requestId)) {
1178
1219
  return text(`请求 ${cmd.requestId} 不支持 ${cmd.decision}。`);
package/src/core/store.ts CHANGED
@@ -47,6 +47,11 @@ export type Conversation = {
47
47
  autoEdit?: boolean;
48
48
  /** 从哪条对话分叉出来的(对话 key),列表里据此显示关联 */
49
49
  forkOf?: string;
50
+ /**
51
+ * 谁的对话(用户 id,见 core/users.ts)。Web 上新建 / 分叉时记下;
52
+ * 飞书会话和升级前就有的 Web 会话没有,归管理员。
53
+ */
54
+ owner?: string;
50
55
  };
51
56
 
52
57
  type StoreShape = Record<string, Conversation>;
@@ -112,6 +117,7 @@ export function ensureConversation(params: {
112
117
  channelId: string;
113
118
  agent: string;
114
119
  title?: string;
120
+ owner?: string;
115
121
  }): Conversation {
116
122
  const existing = getConversation(params.key);
117
123
  if (existing) return existing;
@@ -126,6 +132,7 @@ export function ensureConversation(params: {
126
132
  activeAt: now,
127
133
  createdAt: now,
128
134
  ...(params.title ? { title: params.title } : {}),
135
+ ...(params.owner ? { owner: params.owner } : {}),
129
136
  };
130
137
  load()[params.key] = conv;
131
138
  persist(true);
@@ -181,3 +188,29 @@ export function putConversation(conv: Conversation): void {
181
188
  load()[conv.key] = conv;
182
189
  persist(true);
183
190
  }
191
+
192
+ // ---------- agent 会话的归属 ----------
193
+ //
194
+ // /sessions、/resume 列的是 agent 自己存的全部历史(磁盘上谁开的都有),多用户时要按人过滤:
195
+ // 记下每个 agent 会话 id 是哪个用户的对话里开出来的。没记过的(飞书、终端里开的、升级前的)归管理员。
196
+
197
+ const sessionOwnersPath = path.join(dataDir, "session-owners.json");
198
+ let sessionOwners: Record<string, string> | null = null;
199
+
200
+ function owners(): Record<string, string> {
201
+ sessionOwners ??= readJson<Record<string, string>>(sessionOwnersPath, {});
202
+ return sessionOwners;
203
+ }
204
+
205
+ /** 第一次见到这个会话 id 时记下主人;已经有主了不改 */
206
+ export function recordSessionOwner(sessionId: string | undefined, userId: string | undefined): void {
207
+ if (!sessionId || !userId) return;
208
+ const all = owners();
209
+ if (all[sessionId]) return;
210
+ all[sessionId] = userId;
211
+ writeJsonAtomic(sessionOwnersPath, all);
212
+ }
213
+
214
+ export function sessionOwner(sessionId: string): string | undefined {
215
+ return owners()[sessionId];
216
+ }
package/src/core/types.ts CHANGED
@@ -51,6 +51,11 @@ export type InteractionSpec =
51
51
  detail: string;
52
52
  /** 「总是允许」按钮的文字;不给就没有这个按钮 */
53
53
  alwaysLabel?: string;
54
+ /**
55
+ * 「总是允许」会落盘、对所有会话生效(Claude 的总是允许名单、Codex 的 execpolicy)。
56
+ * 多用户时只有管理员能点 —— 否则一个成员就能替所有人放行。
57
+ */
58
+ alwaysGlobal?: boolean;
54
59
  /** 给了就显示「本会话全部允许」按钮。只有 agent 能真正兑现时才给 */
55
60
  allowSession?: boolean;
56
61
  }
@@ -230,6 +230,18 @@ export function resolveUploadFile(p: string): string | undefined {
230
230
  }
231
231
  }
232
232
 
233
+ /** 上传目录里的文件属于哪条会话(resolveUploadFile 返回的真实路径)。认不出来返回 undefined */
234
+ export function uploadKeyOf(file: string): string | undefined {
235
+ try {
236
+ const rel = path.relative(fs.realpathSync(uploadsRoot), file);
237
+ const first = rel.split(path.sep)[0];
238
+ if (!first || first.startsWith("..") || path.isAbsolute(rel)) return undefined;
239
+ return decodeURIComponent(first);
240
+ } catch {
241
+ return undefined;
242
+ }
243
+ }
244
+
233
245
  /** 附件清单的文字说明,两个 agent 都用它告诉模型文件在哪。 */
234
246
  export function describeAttachments(
235
247
  saved: SavedAttachment[],
@@ -0,0 +1,261 @@
1
+ // 用户:Web 登录的账号。
2
+ //
3
+ // 这是「信任型」多用户:账号分开的是会话列表、授权、通知和设置权限,不是安全边界 ——
4
+ // 所有 agent 都以同一个系统用户运行,谁都能让 agent 去读这台机器上的任何东西。
5
+ // 只把账号给你信任的人(README「多用户」)。
6
+ //
7
+ // 归属规则(ownedBy):
8
+ // - 有主的东西(Web 会话、agent 会话、推送订阅)只属于主人;
9
+ // - 主人已经被删掉了,归管理员;
10
+ // - 没有主的(飞书会话、终端里开的 agent 会话、升级前就有的 Web 会话)归管理员。
11
+ import crypto from "node:crypto";
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import { EventEmitter } from "node:events";
15
+ import { dataDir, readJson, writeJsonAtomic } from "../home.js";
16
+ import { config } from "./config.js";
17
+
18
+ export type Role = "admin" | "member";
19
+
20
+ export type User = {
21
+ id: string;
22
+ /** 登录名,不区分大小写,唯一 */
23
+ name: string;
24
+ role: Role;
25
+ passwordHash: string;
26
+ createdAt: number;
27
+ disabled?: boolean;
28
+ };
29
+
30
+ export type PublicUser = Omit<User, "passwordHash">;
31
+
32
+ type StoreShape = { users: User[] };
33
+
34
+ const usersPath = path.join(dataDir, "users.json");
35
+ /** 用户增删改、停用、改密码:登录态要重新校验 */
36
+ export const userEvents = new EventEmitter<{ changed: [] }>();
37
+
38
+ let cache: User[] | null = null;
39
+ let loadedMtime = 0;
40
+
41
+ function mtimeOf(file: string): number {
42
+ try {
43
+ return fs.statSync(file).mtimeMs;
44
+ } catch {
45
+ return 0;
46
+ }
47
+ }
48
+
49
+ /** 上次看文件有没有变的时间:广播事件时每个连接都要查归属,别每次都 stat 一下 */
50
+ let checkedAt = 0;
51
+ const RECHECK_MS = 1000;
52
+
53
+ function load(): User[] {
54
+ // 终端里 agent-loft user … 会改这个文件:变了就重新读(最多晚 1 秒发现)
55
+ const now = Date.now();
56
+ if (cache && cache.length > 0 && now - checkedAt < RECHECK_MS) return cache;
57
+ checkedAt = now;
58
+ const mtime = mtimeOf(usersPath);
59
+ if (!cache || mtime !== loadedMtime) {
60
+ const raw = readJson<StoreShape>(usersPath, { users: [] });
61
+ cache = Array.isArray(raw.users) ? raw.users.filter((u) => u && typeof u.id === "string" && typeof u.name === "string") : [];
62
+ loadedMtime = mtime;
63
+ }
64
+ migrateLegacy(cache);
65
+ return cache;
66
+ }
67
+
68
+ function save(list: User[]): void {
69
+ cache = list;
70
+ writeJsonAtomic(usersPath, { users: list });
71
+ loadedMtime = mtimeOf(usersPath);
72
+ userEvents.emit("changed");
73
+ }
74
+
75
+ /**
76
+ * 升级前只有一个登录密码(config.web.passwordHash):变成一个叫 admin 的管理员,密码不变。
77
+ * 不改动旧字段 —— 万一回退到老版本还能照常登录;之后改密码时再把它清掉(见 setUserPassword)。
78
+ */
79
+ function migrateLegacy(list: User[]): void {
80
+ if (list.length > 0) return;
81
+ let legacy = "";
82
+ try {
83
+ legacy = config().web.passwordHash;
84
+ } catch {
85
+ return;
86
+ }
87
+ if (!legacy) return;
88
+ list.push({ id: newId(), name: "admin", role: "admin", passwordHash: legacy, createdAt: Date.now() });
89
+ writeJsonAtomic(usersPath, { users: list });
90
+ loadedMtime = mtimeOf(usersPath);
91
+ console.log("[用户] 已把原来的登录密码迁移为管理员账号 admin");
92
+ }
93
+
94
+ function newId(): string {
95
+ return `u_${crypto.randomBytes(5).toString("hex")}`;
96
+ }
97
+
98
+ function publicUser(u: User): PublicUser {
99
+ const { passwordHash: _hidden, ...rest } = u;
100
+ return { ...rest };
101
+ }
102
+
103
+ // ---------- 密码 ----------
104
+
105
+ export function hashPassword(password: string): string {
106
+ const salt = crypto.randomBytes(16);
107
+ const key = crypto.scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 });
108
+ return `scrypt$${salt.toString("hex")}$${key.toString("hex")}`;
109
+ }
110
+
111
+ export function verifyPassword(password: string, stored: string): boolean {
112
+ const [scheme, saltHex, keyHex] = stored.split("$");
113
+ if (scheme !== "scrypt" || !saltHex || !keyHex) return false;
114
+ const expected = Buffer.from(keyHex, "hex");
115
+ const actual = crypto.scryptSync(password, Buffer.from(saltHex, "hex"), expected.length, { N: 16384, r: 8, p: 1 });
116
+ return crypto.timingSafeEqual(expected, actual);
117
+ }
118
+
119
+ /** 用户名不存在时也算一遍 scrypt,响应时间上看不出用户名对不对 */
120
+ let dummyHash: string | null = null;
121
+ const dummy = () => (dummyHash ??= hashPassword(crypto.randomBytes(12).toString("hex")));
122
+
123
+ function checkPassword(password: string): void {
124
+ if (password.length < 8) throw new Error("密码至少 8 位");
125
+ if (password.length > 256) throw new Error("密码太长了");
126
+ }
127
+
128
+ function checkName(name: string): string {
129
+ const n = name.trim();
130
+ if (!/^[\p{L}\p{N}_.@-]{1,32}$/u.test(n)) throw new Error("用户名 1~32 个字符,只能用文字、数字和 _ . @ -");
131
+ return n;
132
+ }
133
+
134
+ // ---------- 查询 ----------
135
+
136
+ export function hasUsers(): boolean {
137
+ return load().length > 0;
138
+ }
139
+
140
+ export function listUsers(): PublicUser[] {
141
+ return load().map(publicUser);
142
+ }
143
+
144
+ export function getUser(id: string | undefined): User | undefined {
145
+ if (!id) return undefined;
146
+ return load().find((u) => u.id === id);
147
+ }
148
+
149
+ export function findUserByName(name: string): User | undefined {
150
+ const n = name.trim().toLowerCase();
151
+ return load().find((u) => u.name.toLowerCase() === n);
152
+ }
153
+
154
+ /** 可以登录的用户(没被停用) */
155
+ export function activeUsers(): User[] {
156
+ return load().filter((u) => !u.disabled);
157
+ }
158
+
159
+ /** 有没有普通成员(没有的话,飞书那边什么都不用滤) */
160
+ export function hasMembers(): boolean {
161
+ return load().some((u) => u.role !== "admin");
162
+ }
163
+
164
+ export function isAdmin(user: Pick<User, "role"> | undefined): boolean {
165
+ return user?.role === "admin";
166
+ }
167
+
168
+ /**
169
+ * 升级前的登录态、推送订阅没有记是谁的:都算最早的那个管理员的(就是迁移出来的 admin)。
170
+ */
171
+ export function legacyOwnerId(): string | undefined {
172
+ return load()
173
+ .filter((u) => u.role === "admin" && !u.disabled)
174
+ .sort((a, b) => a.createdAt - b.createdAt)[0]?.id;
175
+ }
176
+
177
+ /** 归属规则,见文件开头 */
178
+ export function ownedBy(user: Pick<User, "id" | "role">, owner: string | undefined): boolean {
179
+ if (owner) return owner === user.id || (user.role === "admin" && !getUser(owner));
180
+ return user.role === "admin";
181
+ }
182
+
183
+ /** 管理员能看到的(飞书那边是管理员的地盘:/web 只列这些,也只能接上这些) */
184
+ export function adminVisible(owner: string | undefined): boolean {
185
+ if (!owner) return true;
186
+ const u = getUser(owner);
187
+ return !u || u.role === "admin";
188
+ }
189
+
190
+ /**
191
+ * 校验登录。name 没给、并且只有一个能登录的用户时就是他 —— 单用户时登录页和以前一样只要密码。
192
+ */
193
+ export function verifyLogin(name: string | undefined, password: string): User | undefined {
194
+ const active = activeUsers();
195
+ const user = name?.trim() ? active.find((u) => u.name.toLowerCase() === name.trim().toLowerCase()) : active.length === 1 ? active[0] : undefined;
196
+ const ok = verifyPassword(password, user?.passwordHash ?? dummy());
197
+ return ok ? user : undefined;
198
+ }
199
+
200
+ // ---------- 修改 ----------
201
+
202
+ /** 改完之后至少还要有一个能登录的管理员 */
203
+ function assertAdminLeft(list: User[]): void {
204
+ if (!list.some((u) => u.role === "admin" && !u.disabled)) throw new Error("至少要保留一个能登录的管理员");
205
+ }
206
+
207
+ export function createUser(params: { name: string; password: string; role?: Role }): PublicUser {
208
+ const list = load();
209
+ const name = checkName(params.name);
210
+ if (list.some((u) => u.name.toLowerCase() === name.toLowerCase())) throw new Error(`用户名「${name}」已经有人用了`);
211
+ checkPassword(params.password);
212
+ const user: User = {
213
+ id: newId(),
214
+ name,
215
+ role: params.role === "admin" ? "admin" : "member",
216
+ passwordHash: hashPassword(params.password),
217
+ createdAt: Date.now(),
218
+ };
219
+ save([...list, user]);
220
+ return publicUser(user);
221
+ }
222
+
223
+ export function updateUser(id: string, patch: { name?: string; role?: Role; disabled?: boolean; password?: string }): PublicUser {
224
+ const list = load().map((u) => ({ ...u }));
225
+ const user = list.find((u) => u.id === id);
226
+ if (!user) throw new Error("用户不存在");
227
+ if (patch.name !== undefined) {
228
+ const name = checkName(patch.name);
229
+ if (list.some((u) => u.id !== id && u.name.toLowerCase() === name.toLowerCase())) throw new Error(`用户名「${name}」已经有人用了`);
230
+ user.name = name;
231
+ }
232
+ if (patch.role !== undefined) user.role = patch.role === "admin" ? "admin" : "member";
233
+ if (patch.disabled !== undefined) {
234
+ if (patch.disabled) user.disabled = true;
235
+ else delete user.disabled;
236
+ }
237
+ if (patch.password !== undefined) {
238
+ checkPassword(patch.password);
239
+ user.passwordHash = hashPassword(patch.password);
240
+ }
241
+ assertAdminLeft(list);
242
+ save(list);
243
+ return publicUser(user);
244
+ }
245
+
246
+ export function removeUser(id: string): PublicUser {
247
+ const list = load();
248
+ const user = list.find((u) => u.id === id);
249
+ if (!user) throw new Error("用户不存在");
250
+ const rest = list.filter((u) => u.id !== id);
251
+ assertAdminLeft(rest);
252
+ save(rest);
253
+ return publicUser(user);
254
+ }
255
+
256
+ /** 测试用:换了 AGENT_LOFT_HOME 之后重新读 */
257
+ export function resetUsersForTest(): void {
258
+ cache = null;
259
+ loadedMtime = 0;
260
+ checkedAt = 0;
261
+ }