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 +10 -5
- package/bin/cli.js +4 -2
- package/package.json +1 -1
- package/src/agents/claude/session.ts +1 -1
- package/src/agents/codex/session.ts +1 -0
- package/src/channels/feishu/bot.ts +22 -8
- package/src/channels/web/access.ts +26 -0
- package/src/channels/web/api.ts +176 -42
- package/src/channels/web/auth.ts +92 -38
- package/src/channels/web/push.ts +30 -8
- package/src/channels/web/server.ts +50 -17
- package/src/cli/doctor.ts +2 -1
- package/src/cli/passwd.ts +24 -7
- package/src/cli/user.ts +99 -0
- package/src/core/hub.ts +49 -8
- package/src/core/store.ts +33 -0
- package/src/core/types.ts +5 -0
- package/src/core/uploads.ts +12 -0
- package/src/core/users.ts +261 -0
- package/web/app.js +311 -14
- package/web/style.css +28 -2
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
|
|
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
|
|
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
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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, {
|
|
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
|
+
}
|
package/src/channels/web/api.ts
CHANGED
|
@@ -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
|
-
|
|
39
|
+
needsSetup,
|
|
29
40
|
revokeSession,
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
|
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 !!
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
376
|
+
const user = userOp(() => setupFirstAdmin(String(body.name ?? ""), String(body.password ?? "")));
|
|
312
377
|
clearAttempts(ip);
|
|
313
|
-
|
|
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
|
-
|
|
322
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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
|
|
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 ?? ""),
|
|
760
|
+
if (!verifyPassword(String(body.old ?? ""), user.passwordHash)) throw new HttpError(403, "原密码不对");
|
|
681
761
|
clearAttempts(ip);
|
|
682
|
-
|
|
683
|
-
|
|
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
|
-
|
|
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;
|