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/README.md CHANGED
@@ -46,13 +46,14 @@ Logo 就是这个意思:一大两小几颗星(多个 agent)站在同一张
46
46
  - 语音输入:用浏览器自带的语音识别(Chrome / Edge / Safari,需要 HTTPS)。
47
47
  - 新对话页:点大标题里的目录名,在目录树里逐层选工作目录;输入框上方选 agent,右下角选模型和思考程度。会话进行中用 `/agent` 切换 agent,模型和思考程度随时可换。
48
48
  - 输入框里的小圆环显示上下文窗口占用;模型按钮显示实际的模型和思考程度(如「Opus 5.5 high」)。
49
- - 侧栏分「最近」和「项目」两组:Web 会话按工作目录归类,飞书机器人各占一行(右键看历史会话);分叉出来的会话用虚线挂在原会话下面;会话标题由 agent 自动生成,双击重命名。
49
+ - 侧栏分「最近」和「项目」两组:Web 会话按工作目录归类(目录可以拖动排序,右键可置顶),飞书机器人各占一行(右键看历史会话);分叉出来的会话用虚线挂在原会话下面;会话标题由 agent 自动生成,双击重命名。
50
50
  - 侧栏底部显示 Claude / Codex 的订阅额度(5 小时 / 7 天窗口,以及多久后重置)。
51
51
  - 「添加到主屏幕」后当 App 用;切到后台再回来会自动重连,补上错过的内容。
52
52
  - **系统通知**:Web 会话回复完成、出错、需要授权或提问时推送到手机 / 电脑(设置 → 通知 → 「在此设备开启通知」,可以发测试通知)。飞书会话由飞书提醒,正在前台看着的会话不推。需要 HTTPS 访问,iPhone 需 iOS 16.4+ 且从主屏幕图标打开。
53
53
  - **跨渠道同步**:飞书里的会话在 Web 上也能打开接着聊。同一个授权请求同时出现在飞书卡片和网页上,哪边先点算哪边,另一边自动变成「已处理」。
54
54
  - **授权**:允许一次 / 本会话全部允许 / 总是允许(按钮上写明放行范围)/ 拒绝。`/autoedit on` 让 Claude Code 在这条会话里改工作目录内的文件不再询问(Bash 仍询问)。超时一律按拒绝处理。
55
- - **设置都在网页上**:工作目录、会话闲置过期时间、局域网访问和端口、各 agent 的默认模型和思考程度、可选模型、Codex 沙箱、飞书机器人、配对批准、Claude 的「总是允许」名单、通知设备、登录设备管理、服务端升级。
55
+ - **设置都在网页上**:工作目录、会话闲置过期时间、局域网访问和端口、各 agent 的默认模型和思考程度、可选模型、Codex 沙箱、飞书机器人、配对批准、Claude 的「总是允许」名单、通知设备、登录设备管理、用户管理、服务端升级。
56
+ - **多用户**(信任型):管理员在 设置 → 用户 里给别人建账号。每个人只看得到自己的对话、授权和通知,设置只有管理员能改,见下面「安全」。
56
57
 
57
58
  ## 安装
58
59
 
@@ -64,7 +65,7 @@ agent-loft start # 前台运行,日志里会打印首次登
64
65
  agent-loft service install # 注册为开机自启服务(systemd / launchd),日志在 ~/.agent-loft/logs/
65
66
  ```
66
67
 
67
- 打开 `http://<机器 IP>:8800`(本机用 `http://localhost:8800`):第一次需要输入日志里的设置码,然后设一个登录密码。设置码找不到的话,直接运行 `agent-loft passwd` 设密码。
68
+ 打开 `http://<机器 IP>:8800`(本机用 `http://localhost:8800`):第一次需要输入日志里的设置码,然后建一个管理员账号(用户名默认 `admin`)。设置码找不到的话,直接运行 `agent-loft passwd` 设密码。
68
69
 
69
70
  到 设置 → Agent 检查 Claude Code、Codex 装好、登录了没有:Claude Code 随 agent-loft 一起安装,只需在服务端终端里登录;Codex 可以点「直接在服务器上安装」。设置 → 系统 里能看到服务端版本,有新版本时点「升级」即可(`npm install -g agent-loft@latest`,以系统服务运行时会自动重启,打开的页面会自动刷新)。
70
71
 
@@ -106,7 +107,8 @@ agent-loft service install
106
107
  | `agent-loft start` | 启动 |
107
108
  | `agent-loft import [--only claude\|codex] [--disabled] [--dry-run]` | 从旧桥接器导入 |
108
109
  | `agent-loft pair list \| approve <码> \| revoke <机器人id> <open_id>` | 配对管理(Web 设置页里也能做) |
109
- | `agent-loft passwd [新密码]` | 重置 Web 登录密码,所有设备下线 |
110
+ | `agent-loft passwd [新密码] [--user 用户名]` | 重置 Web 登录密码(默认是最早的管理员),他的设备全部下线 |
111
+ | `agent-loft user list \| add <用户名> [--admin] \| passwd <用户名> \| role <用户名> admin\|member \| disable \| enable \| remove` | 管理 Web 登录用户(设置 → 用户 里也能做) |
110
112
  | `agent-loft service install \| uninstall \| restart \| status` | 开机自启 |
111
113
  | `agent-loft doctor` | 体检 |
112
114
 
@@ -120,7 +122,9 @@ agent-loft service install
120
122
 
121
123
  这个服务能让 agent 在你的机器上执行命令,等于一个远程 shell。
122
124
 
123
- - Web 登录密码用 scrypt 哈希保存。首次设置必须用启动日志里的一次性设置码,防止「谁先打开页面谁就成了主人」。
125
+ - Web 登录密码用 scrypt 哈希保存。首次建管理员必须用启动日志里的一次性设置码,防止「谁先打开页面谁就成了主人」。
126
+ - 多用户只隔开**界面上**的东西:会话列表、授权 / 提问卡、通知、`/sessions` 能列出和恢复的 agent 会话;设置、飞书机器人和飞书会话只给管理员;会落盘、对所有人生效的「总是允许」(Claude 的总是允许名单、Codex 的 execpolicy)只有管理员能点。**它不是安全边界**:所有 agent 都以同一个系统用户运行,任何用户都能让 agent 读到这台机器上的文件(别人的对话记录、配置里的密钥),用的也是同一个 Claude / Codex 订阅。只给信任的人开账号;要真正隔离,就每人用自己的系统用户各跑一份 agent-loft。
127
+ - 从老版本升级:原来的登录密码自动变成管理员 `admin`,已登录的设备不用重新登录。
124
128
  - 登录态通过 HttpOnly cookie 保存,服务端只存它的哈希。同一 IP 连续输错 5 次锁 15 分钟。所有写请求和 WebSocket 都校验 Origin。页面开启了严格的 CSP。
125
129
  - 暴露到公网时,只通过 HTTPS 反向代理(比如 Lucky)或 Cloudflare Tunnel 访问,并把不在本机的反代 IP 加进 `web.trustedProxies`(只有从这些地址来的连接才采信 `X-Forwarded-*`)。不要直接把 8800 端口映射出去。局域网访问可以在 设置 → 系统 → 网络访问 里关掉。
126
130
  - 审批超时一律按拒绝处理。Codex 默认沙箱为 `workspace-write`,不会因为 `~/.codex/config.toml` 里配了 `never` 或 `danger-full-access` 就绕过审批。
@@ -135,6 +139,7 @@ src/
135
139
  interactions.ts 审批与提问:一个请求,多个渠道都能处理
136
140
  store.ts 会话持久化(每个 agent 的会话 id 各自保存)
137
141
  config.ts 配置(Web 可改,改完热加载)
142
+ users.ts Web 登录用户和归属规则(谁能看到哪条会话)
138
143
  pairing.ts uploads.ts commands.ts …
139
144
  agents/
140
145
  claude/ Claude Agent SDK 驱动
package/bin/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // 统一入口:agent-loft <start|import|pair|passwd|service|doctor>
2
+ // 统一入口:agent-loft <start|import|pair|passwd|user|service|doctor>
3
3
  //
4
4
  // 源码是 TypeScript,用 tsx 的 ESM loader 直接加载,装完即可运行,不需要先 build。
5
5
  import { register } from "tsx/esm/api";
@@ -9,6 +9,7 @@ const COMMANDS = {
9
9
  import: "../src/cli/import.ts",
10
10
  pair: "../src/cli/pair.ts",
11
11
  passwd: "../src/cli/passwd.ts",
12
+ user: "../src/cli/user.ts",
12
13
  service: "../src/cli/service.ts",
13
14
  doctor: "../src/cli/doctor.ts",
14
15
  };
@@ -19,7 +20,8 @@ const USAGE = `agent-loft —— 用飞书 / Web 驱动本机的 Claude Code 和
19
20
  agent-loft start 启动(默认命令)
20
21
  agent-loft import 从旧的 feishu-claude-bridge / feishu-codex-bridge 导入配置和会话
21
22
  agent-loft pair ... 配对管理:list | approve <配对码> | revoke <机器人id> <open_id>
22
- agent-loft passwd 重置 Web 登录密码
23
+ agent-loft passwd 重置 Web 登录密码(--user 名字 指定用户,默认是管理员)
24
+ agent-loft user ... Web 登录用户:list | add <名字> [--admin] | passwd | role | disable | enable | remove
23
25
  agent-loft service ... 开机自启:install | uninstall | restart | status
24
26
  agent-loft doctor 体检:配置 / agent / 飞书连通性
25
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-loft",
3
- "version": "0.2.22",
3
+ "version": "0.2.24",
4
4
  "type": "module",
5
5
  "description": "多个 agent 的工作台:把本机 Claude Code / Codex 放到一处统一管理,用飞书和 Web 页面随时驱动——会话、审批、模型切换都共用",
6
6
  "keywords": [
@@ -309,7 +309,7 @@ export class ClaudeSession implements AgentSession {
309
309
  kind: "approval",
310
310
  title: `需要授权:${toolName}`,
311
311
  detail: describeToolCall(toolName, input),
312
- ...(isRememberableSignature(toolName, signature) ? { alwaysLabel: alwaysLabelOf(toolName, signature) } : {}),
312
+ ...(isRememberableSignature(toolName, signature) ? { alwaysLabel: alwaysLabelOf(toolName, signature), alwaysGlobal: true } : {}),
313
313
  ...(sessionable ? { allowSession: true } : {}),
314
314
  });
315
315
  if (outcome.settlement === "allow-always" && isRememberableSignature(toolName, signature)) rememberAlways(signature);
@@ -508,6 +508,7 @@ export class CodexSession implements AgentSession {
508
508
  detail: describeCommand(req),
509
509
  // 「总是允许」到底记多宽取决于 Codex 有没有给 execpolicy 补丁,差别很大,得写在按钮上
510
510
  alwaysLabel: amendment ? "以后同类不再问" : "本会话内不再问",
511
+ ...(amendment ? { alwaysGlobal: true } : {}),
511
512
  });
512
513
  const s = outcome.settlement;
513
514
  if (s === "allow-once") return { decision: "accept" };
@@ -13,7 +13,8 @@ import { interactions, type Interaction } from "../../core/interactions.js";
13
13
  import { parseCommand } from "../../core/commands.js";
14
14
  import { Dedup } from "../../core/dedup.js";
15
15
  import { isPaired, requestPairing } from "../../core/pairing.js";
16
- import { getConversation, listConversations, updateConversation } from "../../core/store.js";
16
+ import { getConversation, listConversations, sessionOwner, updateConversation } from "../../core/store.js";
17
+ import { adminVisible, hasMembers } from "../../core/users.js";
17
18
  import { adoptFile, KIND_LABEL } from "../../core/uploads.js";
18
19
  import type { SavedAttachment } from "../../core/types.js";
19
20
  import { FeishuClient } from "./client.js";
@@ -140,14 +141,20 @@ export class FeishuBot {
140
141
  const target = attachedTarget(feishuKey);
141
142
  if (!target) return undefined;
142
143
  const conv = getConversation(target);
143
- return conv && !conv.hidden ? target : undefined;
144
+ // 主人后来被降成了普通成员:这条会话不再是飞书能碰的,当它没了(下条消息时自动退回来)
145
+ return conv && !conv.hidden && adminVisible(conv.owner) ? target : undefined;
146
+ }
147
+
148
+ /** 挂在 Web 会话 key 上的本机器人聊天。会话已经不归管理员了就一个都不算 */
149
+ private watchers(key: string): string[] {
150
+ return adminVisible(getConversation(key)?.owner) ? watchersOf(key, this.prefix) : [];
144
151
  }
145
152
 
146
153
  /** 这条会话的事件该画到哪些飞书聊天里:自己的那个聊天,加上挂着它的聊天 */
147
154
  chatsOf(key: string): string[] {
148
155
  const out = new Set<string>();
149
156
  if (this.mine(key)) out.add(this.chatIdOf(key));
150
- for (const k of watchersOf(key, this.prefix)) out.add(this.chatIdOf(k));
157
+ for (const k of this.watchers(key)) out.add(this.chatIdOf(k));
151
158
  return [...out];
152
159
  }
153
160
 
@@ -155,7 +162,7 @@ export class FeishuBot {
155
162
  private controls(key: string, chatId: string | undefined): boolean {
156
163
  if (!chatId) return false;
157
164
  if (this.mine(key)) return this.chatIdOf(key) === chatId;
158
- return watchersOf(key, this.prefix).some((k) => this.chatIdOf(k) === chatId);
165
+ return this.watchers(key).some((k) => this.chatIdOf(k) === chatId);
159
166
  }
160
167
 
161
168
  private webEntry(key: string): WebConvEntry | undefined {
@@ -206,7 +213,8 @@ export class FeishuBot {
206
213
  */
207
214
  async attachTo(feishuKey: string, webKey: string, operatorOpenId?: string): Promise<string> {
208
215
  const conv = getConversation(webKey);
209
- if (!conv || conv.channel !== "web" || conv.hidden) throw new Error("这条 Web 会话已不存在");
216
+ // 飞书是管理员的地盘:普通成员的 Web 会话不能被接到飞书上
217
+ if (!conv || conv.channel !== "web" || conv.hidden || !adminVisible(conv.owner)) throw new Error("这条 Web 会话已不存在");
210
218
  const chatId = this.chatIdOf(feishuKey);
211
219
  // 本聊天自己的会话也得有:之后 /detach 回来、侧栏里显示机器人都要它
212
220
  const allowed = this.allowedAgents();
@@ -253,8 +261,7 @@ export class FeishuBot {
253
261
  private async sendWebList(feishuKey: string, chatId: string): Promise<void> {
254
262
  const current = this.attachedWeb(feishuKey);
255
263
  const convs = hub
256
- .list()
257
- .filter((c) => c.channel === "web" && !c.hidden)
264
+ .list((c) => c.channel === "web" && !c.hidden && adminVisible(c.owner))
258
265
  .slice(0, 10)
259
266
  .map((c) => this.webEntry(c.key)!)
260
267
  .filter(Boolean);
@@ -613,7 +620,14 @@ export class FeishuBot {
613
620
  const command = attachments.length > 0 ? ({ kind: "chat", text } as const) : parseCommand(text);
614
621
  if (command.kind !== "chat") {
615
622
  try {
616
- const reply = await hub.command(key, command, { allowedAgents: this.allowedFor(key), by: "飞书命令", channel: "feishu" });
623
+ const reply = await hub.command(key, command, {
624
+ allowedAgents: this.allowedFor(key),
625
+ by: "飞书命令",
626
+ channel: "feishu",
627
+ // 普通成员在 Web 上开的 agent 会话、挂着的请求,飞书里都碰不到
628
+ ...(hasMembers() ? { sessionVisible: (id: string) => adminVisible(sessionOwner(id)) } : {}),
629
+ convVisible: (k) => adminVisible(getConversation(k)?.owner),
630
+ });
617
631
  await this.renderReply(key, chatId, reply);
618
632
  } catch (err) {
619
633
  await this.client.sendText(chatId, `出错了:${err instanceof Error ? err.message : String(err)}`);
@@ -0,0 +1,26 @@
1
+ // Web 上「这个用户能碰哪些东西」。规则本身在 core/users.ts 的 ownedBy。
2
+ import { getConversation, sessionOwner, type Conversation } from "../../core/store.js";
3
+ import { legacyOwnerId, listUsers, ownedBy, type User } from "../../core/users.js";
4
+ import type { OwnerFilter } from "./push.js";
5
+
6
+ export function canSeeConv(user: User, conv: Conversation | undefined): conv is Conversation {
7
+ return !!conv && ownedBy(user, conv.owner);
8
+ }
9
+
10
+ export function canSeeKey(user: User, key: string): boolean {
11
+ return canSeeConv(user, getConversation(key));
12
+ }
13
+
14
+ /**
15
+ * 这个用户能看到、能恢复哪些 agent 会话。
16
+ * 只有他一个用户时什么都不会被滤掉,返回 undefined —— 列会话时就不用为了过滤多读一大批
17
+ */
18
+ export function sessionVisibleTo(user: User): ((sessionId: string) => boolean) | undefined {
19
+ if (listUsers().every((u) => u.id === user.id)) return undefined;
20
+ return (sessionId) => ownedBy(user, sessionOwner(sessionId));
21
+ }
22
+
23
+ /** 推送订阅是不是这个用户的。升级前的订阅没记主人,算迁移出来的那个管理员的 */
24
+ export function pushOwnedBy(userId: string): OwnerFilter {
25
+ return (owner) => (owner ?? legacyOwnerId()) === userId;
26
+ }
@@ -9,7 +9,19 @@ import { hub } from "../../core/hub.js";
9
9
  import { interactions } from "../../core/interactions.js";
10
10
  import { approve, listApproved, listPending, revoke } from "../../core/pairing.js";
11
11
  import { getConversation, updateConversation } from "../../core/store.js";
12
- import { resolveUploadFile, saveBuffer, sniff } from "../../core/uploads.js";
12
+ import { resolveUploadFile, saveBuffer, sniff, uploadKeyOf } from "../../core/uploads.js";
13
+ import {
14
+ createUser,
15
+ getUser,
16
+ isAdmin,
17
+ legacyOwnerId,
18
+ listUsers,
19
+ removeUser,
20
+ verifyLogin,
21
+ verifyPassword,
22
+ type Role,
23
+ type User,
24
+ } from "../../core/users.js";
13
25
  import type { SavedAttachment } from "../../core/types.js";
14
26
  import { listAllowlist, setAllowlist } from "../../agents/claude/permissions.js";
15
27
  import { agentAvailability, agentHealth, invalidateHealth } from "../../agents/health.js";
@@ -23,16 +35,19 @@ import {
23
35
  clearAttempts,
24
36
  createSession,
25
37
  destroySession,
26
- hasPassword,
27
38
  listSessions as listWebSessions,
28
- takeAttempt,
39
+ needsSetup,
29
40
  revokeSession,
30
- setPassword,
31
- validateSession,
32
- verifyPassword,
41
+ revokeUserSessions,
42
+ sessionUser,
43
+ patchUser,
44
+ setUserPassword,
45
+ setupFirstAdmin,
46
+ takeAttempt,
33
47
  } from "./auth.js";
48
+ import { canSeeConv, pushOwnedBy, sessionVisibleTo } from "./access.js";
34
49
  import { clientIp, cookies, HttpError, isHttps, readBody, readJsonBody, SECURITY_HEADERS, sendJson } from "./http.js";
35
- import { addSubscription, listSubscriptions, pushToAll, removeSubscription, vapidPublicKey } from "./push.js";
50
+ import { addSubscription, listSubscriptions, pushToAll, removeSubscription, removeSubscriptionsOf, vapidPublicKey } from "./push.js";
36
51
 
37
52
  export const COOKIE = "aim_session";
38
53
  const startedAt = Date.now();
@@ -67,9 +82,13 @@ setInterval(() => {
67
82
  */
68
83
  const INLINE_IMAGE = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
69
84
 
70
- function serveUpload(res: ServerResponse, requested: string, download: boolean): void {
85
+ function serveUpload(res: ServerResponse, user: User, requested: string, download: boolean): void {
71
86
  const file = requested ? resolveUploadFile(requested) : undefined;
72
- if (!file) throw new HttpError(404, "文件不存在(可能会话重置时已清理)");
87
+ // 只能看自己会话里的附件。会话已经没了的,只有管理员能看
88
+ const key = file ? uploadKeyOf(file) : undefined;
89
+ const conv = key ? getConversation(key) : undefined;
90
+ const allowed = conv ? canSeeConv(user, conv) : isAdmin(user);
91
+ if (!file || !allowed) throw new HttpError(404, "文件不存在(可能会话重置时已清理)");
73
92
  const head = Buffer.alloc(12);
74
93
  const fd = fs.openSync(file, "r");
75
94
  try {
@@ -153,7 +172,7 @@ function publicConfig(): unknown {
153
172
  const cfg = structuredClone(config()) as AppConfig & { web: { passwordHash?: string } };
154
173
  return {
155
174
  ...cfg,
156
- web: { ...cfg.web, passwordHash: undefined, hasPassword: !!config().web.passwordHash },
175
+ web: { ...cfg.web, passwordHash: undefined },
157
176
  feishu: { ...cfg.feishu, bots: cfg.feishu.bots.map((b) => ({ ...b, appSecret: maskSecret(b.appSecret) })) },
158
177
  };
159
178
  }
@@ -268,7 +287,46 @@ function setCookie(req: IncomingMessage, res: ServerResponse, token: string, max
268
287
  }
269
288
 
270
289
  export function authed(req: IncomingMessage): boolean {
271
- return !!validateSession(cookies(req)[COOKIE]);
290
+ return !!currentUser(req);
291
+ }
292
+
293
+ /** 这个请求是哪个用户的(没登录 / 登录态失效返回 undefined) */
294
+ export function currentUser(req: IncomingMessage): User | undefined {
295
+ return sessionUser(cookies(req)[COOKIE]);
296
+ }
297
+
298
+ function newSession(req: IncomingMessage, res: ServerResponse, userId: string): void {
299
+ setCookie(req, res, createSession(userId, clientIp(req), String(req.headers["user-agent"] ?? "")), 30 * 24 * 3600);
300
+ }
301
+
302
+ /**
303
+ * 只有管理员能用的接口:所有设置(Agent、飞书、配对、总是允许名单、网络、升级、重启)和用户管理。
304
+ * 写在一处,方便核对。普通成员只能用自己的会话、通知、密码和登录设备。
305
+ */
306
+ function adminOnly(p: string): boolean {
307
+ return (
308
+ p === "/api/config" ||
309
+ p === "/api/server/upgrade" ||
310
+ p === "/api/agents/codex/install" ||
311
+ p.startsWith("/api/pairing") ||
312
+ p.startsWith("/api/claude/") ||
313
+ p.startsWith("/api/feishu/") ||
314
+ p.startsWith("/api/system") ||
315
+ p.startsWith("/api/users")
316
+ );
317
+ }
318
+
319
+ function publicMe(user: User) {
320
+ return { id: user.id, name: user.name, role: user.role };
321
+ }
322
+
323
+ /** users.ts 抛的是给人看的错误,转成 400 */
324
+ function userOp<T>(fn: () => T): T {
325
+ try {
326
+ return fn();
327
+ } catch (err) {
328
+ throw new HttpError(400, err instanceof Error ? err.message : String(err));
329
+ }
272
330
  }
273
331
 
274
332
  // ---------- 路由 ----------
@@ -297,32 +355,38 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
297
355
 
298
356
  // ----- 无需登录 -----
299
357
  if (p === "/api/me" && method === "GET") {
300
- sendJson(res, 200, { authed: authed(req), needsSetup: !hasPassword() });
358
+ const user = currentUser(req);
359
+ sendJson(res, 200, {
360
+ authed: !!user,
361
+ needsSetup: needsSetup(),
362
+ // 不止一个人能登录:登录页要填用户名
363
+ multiUser: listUsers().filter((u) => !u.disabled).length > 1,
364
+ ...(user ? { user: publicMe(user) } : {}),
365
+ });
301
366
  return;
302
367
  }
303
368
  if (p === "/api/setup" && method === "POST") {
304
- if (hasPassword()) throw new HttpError(400, "已经设置过密码了");
305
- const body = await readJsonBody<{ token?: string; password?: string }>(req);
369
+ if (!needsSetup()) throw new HttpError(400, "已经设置过了,直接登录");
370
+ const body = await readJsonBody<{ token?: string; name?: string; password?: string }>(req);
306
371
  const left = takeAttempt(ip);
307
372
  if (left) throw new HttpError(429, `尝试次数太多,请 ${Math.ceil(left / 60000)} 分钟后再试`);
308
373
  if (!checkSetupToken(String(body.token ?? ""))) {
309
374
  throw new HttpError(403, "设置码不对。它打印在 agent-loft 的启动日志里");
310
375
  }
311
- setPassword(String(body.password ?? ""));
376
+ const user = userOp(() => setupFirstAdmin(String(body.name ?? ""), String(body.password ?? "")));
312
377
  clearAttempts(ip);
313
- setCookie(req, res, createSession(ip, String(req.headers["user-agent"] ?? "")), 30 * 24 * 3600);
378
+ newSession(req, res, user.id);
314
379
  sendJson(res, 200, { ok: true });
315
380
  return;
316
381
  }
317
382
  if (p === "/api/login" && method === "POST") {
318
- const body = await readJsonBody<{ password?: string }>(req);
383
+ const body = await readJsonBody<{ name?: string; password?: string }>(req);
319
384
  const left = takeAttempt(ip);
320
385
  if (left) throw new HttpError(429, `尝试次数太多,请 ${Math.ceil(left / 60000)} 分钟后再试`);
321
- if (!hasPassword() || !verifyPassword(String(body.password ?? ""), config().web.passwordHash)) {
322
- throw new HttpError(403, "密码不对");
323
- }
386
+ const user = verifyLogin(typeof body.name === "string" ? body.name : undefined, String(body.password ?? ""));
387
+ if (!user) throw new HttpError(403, listUsers().filter((u) => !u.disabled).length > 1 ? "用户名或密码不对" : "密码不对");
324
388
  clearAttempts(ip);
325
- setCookie(req, res, createSession(ip, String(req.headers["user-agent"] ?? "")), 30 * 24 * 3600);
389
+ newSession(req, res, user.id);
326
390
  sendJson(res, 200, { ok: true });
327
391
  return;
328
392
  }
@@ -333,11 +397,15 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
333
397
  return;
334
398
  }
335
399
 
336
- if (!authed(req)) throw new HttpError(401, "请先登录");
400
+ const user = currentUser(req);
401
+ if (!user) throw new HttpError(401, "请先登录");
402
+ if (adminOnly(p) && !isAdmin(user)) throw new HttpError(403, "只有管理员能做这个操作");
403
+ const admin = isAdmin(user);
404
+ const visible = (c: Parameters<typeof canSeeConv>[1]) => canSeeConv(user, c);
337
405
 
338
406
  // ----- 附件预览 / 下载:只给上传目录里的文件 -----
339
407
  if (p === "/api/file" && method === "GET") {
340
- serveUpload(res, url.searchParams.get("path") ?? "", url.searchParams.get("download") === "1");
408
+ serveUpload(res, user, url.searchParams.get("path") ?? "", url.searchParams.get("download") === "1");
341
409
  return;
342
410
  }
343
411
 
@@ -357,11 +425,13 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
357
425
  ...(agentAvailability(id) ?? {}),
358
426
  };
359
427
  }),
360
- bots: config().feishu.bots.map((b) => ({ id: b.id, name: b.name, agents: b.agents, enabled: b.enabled })),
428
+ // 飞书机器人是管理员配的,飞书会话也只给管理员看
429
+ bots: admin ? config().feishu.bots.map((b) => ({ id: b.id, name: b.name, agents: b.agents, enabled: b.enabled })) : [],
361
430
  workspaceRoot: config().workspaceRoot,
362
431
  quotaRefreshMin: config().web.quotaRefreshMin,
363
- conversations: hub.list(),
364
- pending: interactions.listPending(),
432
+ conversations: hub.list(visible),
433
+ pending: interactions.listPending().filter((i) => visible(getConversation(i.convKey))),
434
+ user: publicMe(user),
365
435
  serverTime: Date.now(),
366
436
  });
367
437
  return;
@@ -369,13 +439,13 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
369
439
 
370
440
  // ----- 系统通知(Web Push) -----
371
441
  if (p === "/api/push" && method === "GET") {
372
- sendJson(res, 200, { publicKey: vapidPublicKey(), subscriptions: listSubscriptions() });
442
+ sendJson(res, 200, { publicKey: vapidPublicKey(), subscriptions: listSubscriptions(pushOwnedBy(user.id)) });
373
443
  return;
374
444
  }
375
445
  if (p === "/api/push/subscribe" && method === "POST") {
376
446
  const body = await readJsonBody<{ subscription?: unknown }>(req);
377
447
  try {
378
- const record = addSubscription(body.subscription, String(req.headers["user-agent"] ?? ""));
448
+ const record = addSubscription(body.subscription, String(req.headers["user-agent"] ?? ""), user.id);
379
449
  sendJson(res, 200, { ok: true, label: record.label });
380
450
  } catch (err) {
381
451
  throw new HttpError(400, err instanceof Error ? err.message : String(err));
@@ -384,11 +454,14 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
384
454
  }
385
455
  if (p === "/api/push/unsubscribe" && method === "POST") {
386
456
  const body = await readJsonBody<{ endpoint?: string }>(req);
387
- sendJson(res, 200, { ok: removeSubscription(String(body.endpoint ?? "")) });
457
+ sendJson(res, 200, { ok: removeSubscription(String(body.endpoint ?? ""), pushOwnedBy(user.id)) });
388
458
  return;
389
459
  }
390
460
  if (p === "/api/push/test" && method === "POST") {
391
- const sent = await pushToAll({ title: "Agent Loft", body: "测试通知:能看到这条,说明通知已经通了。", tag: "test", url: "/" });
461
+ const sent = await pushToAll(
462
+ { title: "Agent Loft", body: "测试通知:能看到这条,说明通知已经通了。", tag: "test", url: "/" },
463
+ pushOwnedBy(user.id),
464
+ );
392
465
  if (sent === 0) throw new HttpError(502, "没有推送成功:还没有设备开启通知,或推送服务拒绝了(看服务日志)");
393
466
  sendJson(res, 200, { sent });
394
467
  return;
@@ -404,7 +477,7 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
404
477
 
405
478
  // ----- 会话 -----
406
479
  if (p === "/api/conversations" && method === "GET") {
407
- sendJson(res, 200, hub.list());
480
+ sendJson(res, 200, hub.list(visible));
408
481
  return;
409
482
  }
410
483
  if (p === "/api/conversations" && method === "POST") {
@@ -419,7 +492,7 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
419
492
  if (!cwd) throw new HttpError(400, `目录必须存在,且在 ${config().workspaceRoot} 之内`);
420
493
  }
421
494
  const key = `web:${crypto.randomBytes(6).toString("hex")}`;
422
- hub.ensure({ key, channel: "web", channelId: "web", defaultAgent: agent });
495
+ hub.ensure({ key, channel: "web", channelId: "web", defaultAgent: agent, owner: user.id });
423
496
  if (cwd && cwd !== config().workspaceRoot) updateConversation(key, { cwd, cwdExplicit: true });
424
497
  sendJson(res, 200, hub.info(key));
425
498
  return;
@@ -430,6 +503,8 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
430
503
  const key = decodeURIComponent(convMatch[1]!);
431
504
  const sub = convMatch[2];
432
505
  const conv = conversationOr404(key);
506
+ // 别人的会话:当它不存在
507
+ if (!visible(conv)) throw new HttpError(404, "会话不存在");
433
508
 
434
509
  if (!sub && method === "PATCH") {
435
510
  const body = await readJsonBody<{ title?: string; hidden?: boolean }>(req);
@@ -514,12 +589,13 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
514
589
  if (sub === "fork" && method === "POST") {
515
590
  const body = await readJsonBody<{ anchor?: string }>(req);
516
591
  const anchor = typeof body.anchor === "string" && body.anchor ? body.anchor : undefined;
517
- const newKey = await hub.forkConversation(key, anchor);
592
+ const newKey = await hub.forkConversation(key, anchor, user.id);
518
593
  sendJson(res, 200, { info: hub.info(newKey) });
519
594
  return;
520
595
  }
521
596
  if (sub === "feishu" && method === "POST") {
522
- // 在飞书继续:把选中的飞书聊天挂到这条 Web 会话上
597
+ // 在飞书继续:把选中的飞书聊天挂到这条 Web 会话上。飞书机器人是管理员的
598
+ if (!admin) throw new HttpError(403, "只有管理员能用飞书机器人");
523
599
  if (conv.channel !== "web" || conv.hidden) throw new HttpError(400, "只有 Web 会话能到飞书继续");
524
600
  const body = await readJsonBody<{ botId?: string; chatId?: string }>(req);
525
601
  const bot = feishuBots().find((b) => b.id === body.botId);
@@ -533,7 +609,7 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
533
609
  return;
534
610
  }
535
611
  if (sub === "sessions" && method === "GET") {
536
- sendJson(res, 200, await hub.sessions(key, url.searchParams.get("all") === "1"));
612
+ sendJson(res, 200, await hub.sessions(key, url.searchParams.get("all") === "1", sessionVisibleTo(user)));
537
613
  return;
538
614
  }
539
615
  if (sub === "models" && method === "GET") {
@@ -556,7 +632,9 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
556
632
  text = await hub.fork(key);
557
633
  break;
558
634
  case "resume":
559
- text = await hub.resume(key, value, body.cwd);
635
+ text = (sessionVisibleTo(user)?.(value) ?? true)
636
+ ? await hub.resume(key, value, body.cwd)
637
+ : `找不到会话 ${value}。\n发 /sessions 看看有哪些可恢复的。`;
560
638
  break;
561
639
  case "model":
562
640
  text = await hub.setModel(key, value);
@@ -662,7 +740,8 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
662
740
  sendJson(res, 404, { error: `没有 agent「${m[1]}」` });
663
741
  return;
664
742
  }
665
- const conv = getConversation(url.searchParams.get("key") ?? "");
743
+ const found = getConversation(url.searchParams.get("key") ?? "");
744
+ const conv = found && visible(found) ? found : undefined;
666
745
  const commands = await hub.commandCatalog(m[1]!, {
667
746
  ...(conv ? { cwd: conv.cwd } : {}),
668
747
  allowedAgents: hub.enabledAgents().map((d) => d.id),
@@ -674,25 +753,80 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
674
753
  }
675
754
  }
676
755
  if (p === "/api/password" && method === "POST") {
756
+ // 改自己的密码:自己所有设备下线,当前这台换一个新的登录态
677
757
  const body = await readJsonBody<{ old?: string; password?: string }>(req);
678
758
  const left = takeAttempt(ip);
679
759
  if (left) throw new HttpError(429, `尝试次数太多,请 ${Math.ceil(left / 60000)} 分钟后再试`);
680
- if (!verifyPassword(String(body.old ?? ""), config().web.passwordHash)) throw new HttpError(403, "原密码不对");
760
+ if (!verifyPassword(String(body.old ?? ""), user.passwordHash)) throw new HttpError(403, "原密码不对");
681
761
  clearAttempts(ip);
682
- setPassword(String(body.password ?? ""));
683
- setCookie(req, res, createSession(ip, String(req.headers["user-agent"] ?? "")), 30 * 24 * 3600);
762
+ userOp(() => setUserPassword(user.id, String(body.password ?? "")));
763
+ newSession(req, res, user.id);
684
764
  sendJson(res, 200, { ok: true });
685
765
  return;
686
766
  }
687
767
  if (p === "/api/web-sessions" && method === "GET") {
688
- sendJson(res, 200, listWebSessions());
768
+ // 管理员看所有人的设备,成员只看自己的
769
+ sendJson(res, 200, listWebSessions(admin ? undefined : user.id));
689
770
  return;
690
771
  }
691
772
  if (p === "/api/web-sessions/revoke" && method === "POST") {
692
773
  const body = await readJsonBody<{ id?: string }>(req);
693
- sendJson(res, 200, { ok: revokeSession(String(body.id ?? "")) });
774
+ sendJson(res, 200, { ok: revokeSession(String(body.id ?? ""), admin ? undefined : user.id) });
775
+ return;
776
+ }
777
+
778
+ // ----- 用户管理(管理员) -----
779
+ if (p === "/api/users" && method === "GET") {
780
+ const devices = listWebSessions();
781
+ sendJson(res, 200, {
782
+ users: listUsers().map((u) => ({ ...u, devices: devices.filter((d) => d.userId === u.id).length })),
783
+ me: user.id,
784
+ });
694
785
  return;
695
786
  }
787
+ if (p === "/api/users" && method === "POST") {
788
+ const body = await readJsonBody<{ name?: string; password?: string; role?: Role }>(req);
789
+ sendJson(
790
+ res,
791
+ 200,
792
+ userOp(() => createUser({ name: String(body.name ?? ""), password: String(body.password ?? ""), role: body.role === "admin" ? "admin" : "member" })),
793
+ );
794
+ return;
795
+ }
796
+ const userMatch = /^\/api\/users\/([A-Za-z0-9_]+)$/.exec(p);
797
+ if (userMatch) {
798
+ const id = userMatch[1]!;
799
+ if (!getUser(id)) throw new HttpError(404, "用户不存在");
800
+ if (method === "PATCH") {
801
+ const body = await readJsonBody<{ name?: string; role?: Role; disabled?: boolean; password?: string }>(req);
802
+ if (id === user.id && (body.disabled === true || (body.role !== undefined && body.role !== "admin"))) {
803
+ throw new HttpError(400, "不能停用自己,也不能取消自己的管理员");
804
+ }
805
+ // 一次改完:密码不合格时别的也不改。重置密码 / 停用会让他的设备全部下线
806
+ const updated = userOp(() =>
807
+ patchUser(id, {
808
+ ...(typeof body.name === "string" ? { name: body.name } : {}),
809
+ ...(body.role === "admin" || body.role === "member" ? { role: body.role } : {}),
810
+ ...(typeof body.disabled === "boolean" ? { disabled: body.disabled } : {}),
811
+ ...(typeof body.password === "string" ? { password: body.password } : {}),
812
+ }),
813
+ );
814
+ // 管理员在用户列表里重置了自己的密码:当前这台换个新登录态,别把自己踢出去
815
+ if (id === user.id && typeof body.password === "string") newSession(req, res, user.id);
816
+ sendJson(res, 200, updated);
817
+ return;
818
+ }
819
+ if (method === "DELETE") {
820
+ if (id === user.id) throw new HttpError(400, "不能删除自己");
821
+ // 升级前没记主人的推送订阅算在最早的管理员名下:删人之前先记下是谁
822
+ const legacy = legacyOwnerId();
823
+ const removed = userOp(() => removeUser(id));
824
+ removeSubscriptionsOf((owner) => (owner ?? legacy) === id);
825
+ revokeUserSessions(id);
826
+ sendJson(res, 200, removed);
827
+ return;
828
+ }
829
+ }
696
830
  if (p === "/api/pairing" && method === "GET") {
697
831
  sendJson(res, 200, { pending: listPending(), approved: listApproved() });
698
832
  return;