agent-loft 0.2.21 → 0.2.23

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/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
+ }