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/src/channels/web/auth.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Web 登录鉴权。
|
|
2
2
|
//
|
|
3
3
|
// 这个页面能让 agent 在这台机器上执行命令,等同于一个远程 shell,所以:
|
|
4
|
-
// - 密码用 scrypt
|
|
5
|
-
// -
|
|
4
|
+
// - 密码用 scrypt 哈希存,不存明文(账号见 core/users.ts);
|
|
5
|
+
// - 一个用户都没有时,必须拿终端 / 日志里打印的一次性设置码才能建第一个管理员
|
|
6
6
|
// (否则谁先打开页面谁就成了主人);
|
|
7
7
|
// - 登录态是随机 token,服务端只存它的哈希,HttpOnly cookie;
|
|
8
8
|
// - 同一 IP 连续输错会被锁一段时间。
|
|
@@ -11,16 +11,20 @@ import fs from "node:fs";
|
|
|
11
11
|
import { EventEmitter } from "node:events";
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
import { config, saveConfig } from "../../core/config.js";
|
|
14
|
+
import { createUser, getUser, hasUsers, legacyOwnerId, updateUser, userEvents, type PublicUser, type User } from "../../core/users.js";
|
|
14
15
|
import { dataDir, readJson, writeJsonAtomic } from "../../home.js";
|
|
15
16
|
|
|
16
17
|
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
17
18
|
|
|
18
|
-
/**
|
|
19
|
+
/** 登录态被撤销(退出、改密码、踢设备、用户被停用 / 删除)时通知 WebSocket 那边立刻断开对应连接 */
|
|
19
20
|
export const authEvents = new EventEmitter<{ revoked: [] }>();
|
|
21
|
+
userEvents.on("changed", () => authEvents.emit("revoked"));
|
|
20
22
|
const sessionsPath = path.join(dataDir, "web-sessions.json");
|
|
21
23
|
const setupTokenPath = path.join(dataDir, "web-setup-token");
|
|
22
24
|
|
|
23
25
|
export type WebSession = {
|
|
26
|
+
/** 升级前的登录态没有这个字段:读的时候认给迁移出来的那个管理员(legacyOwnerId) */
|
|
27
|
+
userId?: string;
|
|
24
28
|
createdAt: number;
|
|
25
29
|
expiresAt: number;
|
|
26
30
|
lastSeen: number;
|
|
@@ -47,7 +51,19 @@ function loadSessions(): Record<string, WebSession> {
|
|
|
47
51
|
loadedMtime = mtime;
|
|
48
52
|
}
|
|
49
53
|
const now = Date.now();
|
|
50
|
-
|
|
54
|
+
let stamped = false;
|
|
55
|
+
for (const [id, s] of Object.entries(sessions)) {
|
|
56
|
+
if (s.expiresAt < now) delete sessions[id];
|
|
57
|
+
else if (!s.userId) {
|
|
58
|
+
// 升级前的登录态:认给迁移出来的管理员,并且记下来,之后那个管理员被删了也不会换人
|
|
59
|
+
const owner = legacyOwnerId();
|
|
60
|
+
if (owner) {
|
|
61
|
+
s.userId = owner;
|
|
62
|
+
stamped = true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (stamped) saveSessions();
|
|
51
67
|
return sessions;
|
|
52
68
|
}
|
|
53
69
|
|
|
@@ -60,43 +76,34 @@ function sha256(value: string): string {
|
|
|
60
76
|
return crypto.createHash("sha256").update(value).digest("hex");
|
|
61
77
|
}
|
|
62
78
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
export function hashPassword(password: string): string {
|
|
66
|
-
const salt = crypto.randomBytes(16);
|
|
67
|
-
const key = crypto.scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 });
|
|
68
|
-
return `scrypt$${salt.toString("hex")}$${key.toString("hex")}`;
|
|
79
|
+
function ownerOf(s: WebSession): string | undefined {
|
|
80
|
+
return s.userId;
|
|
69
81
|
}
|
|
70
82
|
|
|
71
|
-
|
|
72
|
-
const [scheme, saltHex, keyHex] = stored.split("$");
|
|
73
|
-
if (scheme !== "scrypt" || !saltHex || !keyHex) return false;
|
|
74
|
-
const expected = Buffer.from(keyHex, "hex");
|
|
75
|
-
const actual = crypto.scryptSync(password, Buffer.from(saltHex, "hex"), expected.length, { N: 16384, r: 8, p: 1 });
|
|
76
|
-
return crypto.timingSafeEqual(expected, actual);
|
|
77
|
-
}
|
|
83
|
+
// ---------- 首次设置 ----------
|
|
78
84
|
|
|
79
|
-
|
|
80
|
-
|
|
85
|
+
/** 还一个用户都没有:页面上要先建管理员 */
|
|
86
|
+
export function needsSetup(): boolean {
|
|
87
|
+
return !hasUsers();
|
|
81
88
|
}
|
|
82
89
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
90
|
+
/**
|
|
91
|
+
* 建第一个管理员(页面上用设置码,或终端里 agent-loft passwd)。
|
|
92
|
+
* 之前残留的登录态全部作废:否则它们没记用户,会被当成升级前的登录态认给这个新管理员。
|
|
93
|
+
*/
|
|
94
|
+
export function setupFirstAdmin(name: string, password: string): PublicUser {
|
|
95
|
+
if (hasUsers()) throw new Error("已经设置过了");
|
|
96
|
+
const user = createUser({ name: name.trim() || "admin", password, role: "admin" });
|
|
89
97
|
sessions = {};
|
|
90
98
|
saveSessions();
|
|
91
99
|
fs.rmSync(setupTokenPath, { force: true });
|
|
92
100
|
authEvents.emit("revoked");
|
|
101
|
+
return user;
|
|
93
102
|
}
|
|
94
103
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
/** 没有密码时生成(或沿用)一次性设置码,打印到日志里。 */
|
|
104
|
+
/** 一个用户都没有时生成(或沿用)一次性设置码,打印到日志里。 */
|
|
98
105
|
export function ensureSetupToken(): string | null {
|
|
99
|
-
if (
|
|
106
|
+
if (hasUsers()) return null;
|
|
100
107
|
try {
|
|
101
108
|
const existing = fs.readFileSync(setupTokenPath, "utf8").trim();
|
|
102
109
|
if (existing) return existing;
|
|
@@ -117,26 +124,54 @@ export function checkSetupToken(token: string): boolean {
|
|
|
117
124
|
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
118
125
|
}
|
|
119
126
|
|
|
127
|
+
// ---------- 密码 ----------
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 改某个用户的密码:他所有已登录的设备下线。
|
|
131
|
+
* 升级前那个全局密码(config.web.passwordHash)也一并清掉,旧密码从此不再有效。
|
|
132
|
+
*/
|
|
133
|
+
export function setUserPassword(userId: string, password: string): void {
|
|
134
|
+
patchUser(userId, { password });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 改用户(一次写盘,要么全改要么全不改),再处理副作用:
|
|
139
|
+
* 改了密码或者停用了,他所有设备下线;改了密码,升级前的全局密码也作废。
|
|
140
|
+
*/
|
|
141
|
+
export function patchUser(userId: string, patch: Parameters<typeof updateUser>[1]): PublicUser {
|
|
142
|
+
const updated = updateUser(userId, patch);
|
|
143
|
+
if (patch.password !== undefined || patch.disabled === true) revokeUserSessions(userId);
|
|
144
|
+
if (patch.password !== undefined && config().web.passwordHash) {
|
|
145
|
+
const cfg = structuredClone(config());
|
|
146
|
+
cfg.web.passwordHash = "";
|
|
147
|
+
saveConfig(cfg);
|
|
148
|
+
}
|
|
149
|
+
return updated;
|
|
150
|
+
}
|
|
151
|
+
|
|
120
152
|
// ---------- 登录态 ----------
|
|
121
153
|
|
|
122
|
-
export function createSession(ip: string, userAgent: string): string {
|
|
154
|
+
export function createSession(userId: string, ip: string, userAgent: string): string {
|
|
123
155
|
const token = crypto.randomBytes(32).toString("base64url");
|
|
124
156
|
const now = Date.now();
|
|
125
|
-
loadSessions()[sha256(token)] = { createdAt: now, expiresAt: now + SESSION_TTL_MS, lastSeen: now, ip, userAgent };
|
|
157
|
+
loadSessions()[sha256(token)] = { userId, createdAt: now, expiresAt: now + SESSION_TTL_MS, lastSeen: now, ip, userAgent };
|
|
126
158
|
saveSessions();
|
|
127
159
|
return token;
|
|
128
160
|
}
|
|
129
161
|
|
|
130
|
-
|
|
131
|
-
|
|
162
|
+
/** 登录态对应的用户。用户被停用、删掉了也算无效 */
|
|
163
|
+
export function sessionUser(token: string | undefined): User | undefined {
|
|
164
|
+
if (!token || !hasUsers()) return undefined;
|
|
132
165
|
const s = loadSessions()[sha256(token)];
|
|
133
166
|
if (!s) return undefined;
|
|
167
|
+
const user = getUser(ownerOf(s));
|
|
168
|
+
if (!user || user.disabled) return undefined;
|
|
134
169
|
const now = Date.now();
|
|
135
170
|
if (now - s.lastSeen > 60_000) {
|
|
136
171
|
s.lastSeen = now;
|
|
137
172
|
saveSessions();
|
|
138
173
|
}
|
|
139
|
-
return
|
|
174
|
+
return user;
|
|
140
175
|
}
|
|
141
176
|
|
|
142
177
|
export function destroySession(token: string | undefined): void {
|
|
@@ -146,13 +181,24 @@ export function destroySession(token: string | undefined): void {
|
|
|
146
181
|
authEvents.emit("revoked");
|
|
147
182
|
}
|
|
148
183
|
|
|
149
|
-
export
|
|
150
|
-
|
|
184
|
+
export type SessionView = WebSession & { id: string; userId?: string; userName?: string };
|
|
185
|
+
|
|
186
|
+
/** 已登录的设备。给了 userId 就只列这个用户的 */
|
|
187
|
+
export function listSessions(userId?: string): SessionView[] {
|
|
188
|
+
return Object.entries(loadSessions())
|
|
189
|
+
.filter(([, s]) => !userId || ownerOf(s) === userId)
|
|
190
|
+
.map(([id, s]) => {
|
|
191
|
+
const owner = ownerOf(s);
|
|
192
|
+
const name = getUser(owner)?.name;
|
|
193
|
+
return { ...s, id: id.slice(0, 12), ...(owner ? { userId: owner } : {}), ...(name ? { userName: name } : {}) };
|
|
194
|
+
});
|
|
151
195
|
}
|
|
152
196
|
|
|
153
|
-
|
|
197
|
+
/** 踢掉一个设备。给了 userId 就只能踢这个用户自己的 */
|
|
198
|
+
export function revokeSession(idPrefix: string, userId?: string): boolean {
|
|
199
|
+
if (!idPrefix) return false;
|
|
154
200
|
const all = loadSessions();
|
|
155
|
-
const hit = Object.keys(all).find((id) => id.startsWith(idPrefix));
|
|
201
|
+
const hit = Object.keys(all).find((id) => id.startsWith(idPrefix) && (!userId || ownerOf(all[id]!) === userId));
|
|
156
202
|
if (!hit) return false;
|
|
157
203
|
delete all[hit];
|
|
158
204
|
saveSessions();
|
|
@@ -160,6 +206,14 @@ export function revokeSession(idPrefix: string): boolean {
|
|
|
160
206
|
return true;
|
|
161
207
|
}
|
|
162
208
|
|
|
209
|
+
/** 某个用户的所有设备下线 */
|
|
210
|
+
export function revokeUserSessions(userId: string): void {
|
|
211
|
+
const all = loadSessions();
|
|
212
|
+
for (const [id, s] of Object.entries(all)) if (ownerOf(s) === userId) delete all[id];
|
|
213
|
+
saveSessions();
|
|
214
|
+
authEvents.emit("revoked");
|
|
215
|
+
}
|
|
216
|
+
|
|
163
217
|
// ---------- 防爆破 ----------
|
|
164
218
|
//
|
|
165
219
|
// 额度在「真要验密码的那一刻」同步扣掉,而不是先查后记:后者中间隔着读请求体的
|
package/src/channels/web/push.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import crypto from "node:crypto";
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import { dataDir, readJson, writeJsonAtomic } from "../../home.js";
|
|
11
|
+
import { legacyOwnerId } from "../../core/users.js";
|
|
11
12
|
|
|
12
13
|
export type PushSubscriptionRecord = {
|
|
13
14
|
endpoint: string;
|
|
@@ -16,8 +17,13 @@ export type PushSubscriptionRecord = {
|
|
|
16
17
|
label: string;
|
|
17
18
|
createdAt: number;
|
|
18
19
|
lastOkAt?: number;
|
|
20
|
+
/** 哪个用户开的(core/users.ts)。升级前的没有:第一次读到时认给迁移出来的那个管理员并记下 */
|
|
21
|
+
userId?: string;
|
|
19
22
|
};
|
|
20
23
|
|
|
24
|
+
/** 按订阅的主人筛选:拿到的是订阅里记的 userId(可能没有) */
|
|
25
|
+
export type OwnerFilter = (userId: string | undefined) => boolean;
|
|
26
|
+
|
|
21
27
|
export type PushPayload = {
|
|
22
28
|
title: string;
|
|
23
29
|
body: string;
|
|
@@ -42,6 +48,12 @@ function load(): Store {
|
|
|
42
48
|
if (!store) {
|
|
43
49
|
store = readJson<Store>(FILE(), { subscriptions: [] });
|
|
44
50
|
if (!Array.isArray(store.subscriptions)) store.subscriptions = [];
|
|
51
|
+
// 升级前的订阅:记到迁移出来的管理员名下。只做一次 —— 之后那个管理员被停用、删掉,
|
|
52
|
+
// 订阅也不会转给别人(那样别人的通知会推到他的手机上)
|
|
53
|
+
const owner = legacyOwnerId();
|
|
54
|
+
const legacy = owner ? store.subscriptions.filter((x) => !x.userId) : [];
|
|
55
|
+
for (const x of legacy) x.userId = owner;
|
|
56
|
+
if (legacy.length > 0) writeJsonAtomic(FILE(), store);
|
|
45
57
|
}
|
|
46
58
|
return store;
|
|
47
59
|
}
|
|
@@ -134,7 +146,8 @@ function describeUa(ua: string): string {
|
|
|
134
146
|
return browser ? `${device} · ${browser}` : device;
|
|
135
147
|
}
|
|
136
148
|
|
|
137
|
-
|
|
149
|
+
/** 同一个浏览器换了个账号登录再开通知:订阅归新账号 */
|
|
150
|
+
export function addSubscription(sub: unknown, userAgent: string, userId?: string): PushSubscriptionRecord {
|
|
138
151
|
if (!validSubscription(sub)) throw new Error("订阅信息不完整");
|
|
139
152
|
const s = load();
|
|
140
153
|
const record: PushSubscriptionRecord = {
|
|
@@ -142,24 +155,25 @@ export function addSubscription(sub: unknown, userAgent: string): PushSubscripti
|
|
|
142
155
|
keys: { p256dh: sub.keys.p256dh, auth: sub.keys.auth },
|
|
143
156
|
label: describeUa(userAgent),
|
|
144
157
|
createdAt: Date.now(),
|
|
158
|
+
...(userId ? { userId } : {}),
|
|
145
159
|
};
|
|
146
160
|
s.subscriptions = [...s.subscriptions.filter((x) => x.endpoint !== sub.endpoint), record];
|
|
147
161
|
persist();
|
|
148
162
|
return record;
|
|
149
163
|
}
|
|
150
164
|
|
|
151
|
-
export function removeSubscription(endpoint: string): boolean {
|
|
165
|
+
export function removeSubscription(endpoint: string, mine?: OwnerFilter): boolean {
|
|
152
166
|
const s = load();
|
|
153
167
|
const before = s.subscriptions.length;
|
|
154
|
-
s.subscriptions = s.subscriptions.filter((x) => x.endpoint !== endpoint);
|
|
168
|
+
s.subscriptions = s.subscriptions.filter((x) => x.endpoint !== endpoint || (mine && !mine(x.userId)));
|
|
155
169
|
if (s.subscriptions.length === before) return false;
|
|
156
170
|
persist();
|
|
157
171
|
return true;
|
|
158
172
|
}
|
|
159
173
|
|
|
160
174
|
/** 给设置页看的:不带密钥 */
|
|
161
|
-
export function listSubscriptions(): { endpoint: string; label: string; createdAt: number; lastOkAt?: number }[] {
|
|
162
|
-
return load().subscriptions.map(({ endpoint, label, createdAt, lastOkAt }) => ({
|
|
175
|
+
export function listSubscriptions(mine?: OwnerFilter): { endpoint: string; label: string; createdAt: number; lastOkAt?: number }[] {
|
|
176
|
+
return load().subscriptions.filter((x) => !mine || mine(x.userId)).map(({ endpoint, label, createdAt, lastOkAt }) => ({
|
|
163
177
|
endpoint,
|
|
164
178
|
label,
|
|
165
179
|
createdAt,
|
|
@@ -201,9 +215,17 @@ async function sendOne(sub: PushSubscriptionRecord, payload: PushPayload): Promi
|
|
|
201
215
|
return true;
|
|
202
216
|
}
|
|
203
217
|
|
|
204
|
-
/**
|
|
205
|
-
export
|
|
206
|
-
const
|
|
218
|
+
/** 某个用户的订阅全部删掉(删用户时) */
|
|
219
|
+
export function removeSubscriptionsOf(mine: OwnerFilter): void {
|
|
220
|
+
const s = load();
|
|
221
|
+
const before = s.subscriptions.length;
|
|
222
|
+
s.subscriptions = s.subscriptions.filter((x) => !mine(x.userId));
|
|
223
|
+
if (s.subscriptions.length !== before) persist();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** 推给订阅的设备(给了 to 就只推给它挑出来的主人),返回成功的数量。单个设备失败不影响别的 */
|
|
227
|
+
export async function pushToAll(payload: PushPayload, to?: OwnerFilter): Promise<number> {
|
|
228
|
+
const subs = load().subscriptions.filter((x) => !to || to(x.userId));
|
|
207
229
|
if (subs.length === 0) return 0;
|
|
208
230
|
const results = await Promise.all(
|
|
209
231
|
subs.map((sub) =>
|
|
@@ -10,13 +10,15 @@ import { fileURLToPath } from "node:url";
|
|
|
10
10
|
import { WebSocketServer, type WebSocket } from "ws";
|
|
11
11
|
import { config, onConfigChange } from "../../core/config.js";
|
|
12
12
|
import { parseCommand } from "../../core/commands.js";
|
|
13
|
-
import { hub, type EventEnvelope } from "../../core/hub.js";
|
|
13
|
+
import { GLOBAL_ALWAYS_DENIED, hub, type EventEnvelope } from "../../core/hub.js";
|
|
14
14
|
import { interactions, type Interaction } from "../../core/interactions.js";
|
|
15
15
|
import { getConversation } from "../../core/store.js";
|
|
16
16
|
import { RUNNING_VERSION } from "../../core/upgrade.js";
|
|
17
17
|
import type { Settlement } from "../../core/types.js";
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
18
|
+
import { getUser, isAdmin, legacyOwnerId, type User } from "../../core/users.js";
|
|
19
|
+
import { currentUser, handleApi, takeUploads } from "./api.js";
|
|
20
|
+
import { authEvents, ensureSetupToken, sessionUser } from "./auth.js";
|
|
21
|
+
import { canSeeKey, sessionVisibleTo } from "./access.js";
|
|
20
22
|
import { cookies } from "./http.js";
|
|
21
23
|
import { COOKIE } from "./api.js";
|
|
22
24
|
import { HttpError, sameOrigin, SECURITY_HEADERS, sendJson } from "./http.js";
|
|
@@ -134,6 +136,8 @@ type Client = {
|
|
|
134
136
|
subs: Set<string>;
|
|
135
137
|
alive: boolean;
|
|
136
138
|
token: string;
|
|
139
|
+
/** 登录的用户。每次复查登录态时刷新(角色可能被改了) */
|
|
140
|
+
user: User;
|
|
137
141
|
/** 页面在前台,且正看着哪条会话(前端在切会话、切前后台时上报) */
|
|
138
142
|
visible: boolean;
|
|
139
143
|
viewing: string | null;
|
|
@@ -142,12 +146,18 @@ type Client = {
|
|
|
142
146
|
/** 每次进程启动换一个:客户端发现变了,就知道 seq 已经从头计数,得整页重拉 */
|
|
143
147
|
const EPOCH = Math.random().toString(36).slice(2, 10);
|
|
144
148
|
|
|
145
|
-
/**
|
|
146
|
-
function checkClient(client: Client):
|
|
147
|
-
|
|
149
|
+
/** 登录态已失效(退出、被踢、用户被停用 / 删除)的连接:断开。客户端收到 4401 会回到登录页 */
|
|
150
|
+
function checkClient(client: Client): User | undefined {
|
|
151
|
+
const user = sessionUser(client.token);
|
|
152
|
+
if (user) {
|
|
153
|
+
client.user = user;
|
|
154
|
+
// 角色被改了(管理员降成成员):已经订阅着的、现在看不到的会话,事件不能再推给他
|
|
155
|
+
for (const key of client.subs) if (!canSeeKey(user, key)) client.subs.delete(key);
|
|
156
|
+
return user;
|
|
157
|
+
}
|
|
148
158
|
client.ws.close(4401, "登录已失效");
|
|
149
159
|
clients.delete(client);
|
|
150
|
-
return
|
|
160
|
+
return undefined;
|
|
151
161
|
}
|
|
152
162
|
const clients = new Set<Client>();
|
|
153
163
|
|
|
@@ -160,13 +170,14 @@ function broadcastEvent(env: EventEnvelope): void {
|
|
|
160
170
|
const listChanged = ["user", "meta", "state", "result", "error"].includes(env.ev.type);
|
|
161
171
|
const info = listChanged ? hub.info(env.key) : undefined;
|
|
162
172
|
for (const client of [...clients]) {
|
|
173
|
+
// 订阅时已经核对过是不是他的会话
|
|
163
174
|
if (client.subs.has(env.key)) send(client, { t: "ev", ...env });
|
|
164
|
-
if (info) send(client, { t: "conv", info });
|
|
175
|
+
if (info && canSeeKey(client.user, env.key)) send(client, { t: "conv", info });
|
|
165
176
|
}
|
|
166
177
|
}
|
|
167
178
|
|
|
168
179
|
function broadcastInteraction(i: Interaction): void {
|
|
169
|
-
for (const client of clients) send(client, { t: "ix", interaction: i });
|
|
180
|
+
for (const client of clients) if (canSeeKey(client.user, i.convKey)) send(client, { t: "ix", interaction: i });
|
|
170
181
|
}
|
|
171
182
|
|
|
172
183
|
// ---------- 系统通知(Web Push) ----------
|
|
@@ -210,8 +221,14 @@ type Notice = { title: string; body: string; tag: string };
|
|
|
210
221
|
*/
|
|
211
222
|
function notify(key: string, notice: Notice): void {
|
|
212
223
|
if (!isWebConv(key)) return;
|
|
213
|
-
for (const client of clients) send(client, { t: "notice", key, ...notice });
|
|
214
|
-
if (hasSubscriptions()
|
|
224
|
+
for (const client of clients) if (canSeeKey(client.user, key)) send(client, { t: "notice", key, ...notice });
|
|
225
|
+
if (!hasSubscriptions() || watching(key)) return;
|
|
226
|
+
// 只推给这条会话的主人(没记主人的订阅算迁移出来的那个管理员的)
|
|
227
|
+
const legacy = legacyOwnerId();
|
|
228
|
+
void pushToAll({ ...notice, url: `/#${encodeURIComponent(key)}` }, (owner) => {
|
|
229
|
+
const u = getUser(owner ?? legacy);
|
|
230
|
+
return !!u && !u.disabled && canSeeKey(u, key);
|
|
231
|
+
});
|
|
215
232
|
}
|
|
216
233
|
|
|
217
234
|
function pushEvent(env: EventEnvelope): void {
|
|
@@ -240,7 +257,8 @@ const BY = "已在 Web 处理";
|
|
|
240
257
|
|
|
241
258
|
function handleClientMessage(client: Client, raw: string): void {
|
|
242
259
|
// 每条消息都复查登录态:握手时有效不代表现在还有效(改了密码、被踢了)
|
|
243
|
-
|
|
260
|
+
const user = checkClient(client);
|
|
261
|
+
if (!user) return;
|
|
244
262
|
let msg: Record<string, unknown>;
|
|
245
263
|
try {
|
|
246
264
|
const parsed: unknown = JSON.parse(raw);
|
|
@@ -260,7 +278,7 @@ function handleClientMessage(client: Client, raw: string): void {
|
|
|
260
278
|
return;
|
|
261
279
|
|
|
262
280
|
case "sub": {
|
|
263
|
-
if (!getConversation(key)) return ack(false, { error: "会话不存在" });
|
|
281
|
+
if (!getConversation(key) || !canSeeKey(user, key)) return ack(false, { error: "会话不存在" });
|
|
264
282
|
client.subs.add(key);
|
|
265
283
|
const since = typeof msg.since === "number" ? msg.since : 0;
|
|
266
284
|
// 客户端的 seq 来自上一个进程(比现在的还大),或者换了实例:补不了,整页重拉
|
|
@@ -291,7 +309,7 @@ function handleClientMessage(client: Client, raw: string): void {
|
|
|
291
309
|
return ack(true);
|
|
292
310
|
|
|
293
311
|
case "send": {
|
|
294
|
-
if (!getConversation(key)) return ack(false, { error: "会话不存在" });
|
|
312
|
+
if (!getConversation(key) || !canSeeKey(user, key)) return ack(false, { error: "会话不存在" });
|
|
295
313
|
const text = typeof msg.text === "string" ? msg.text : "";
|
|
296
314
|
const attachments = takeUploads(key, msg.uploads);
|
|
297
315
|
if (!text.trim() && attachments.length === 0) return ack(false, { error: "空消息" });
|
|
@@ -301,7 +319,16 @@ function handleClientMessage(client: Client, raw: string): void {
|
|
|
301
319
|
if (command && command.kind !== "chat") {
|
|
302
320
|
const allowedAgents = hub.enabledAgents().map((d) => d.id);
|
|
303
321
|
void hub
|
|
304
|
-
.command(key, command, {
|
|
322
|
+
.command(key, command, {
|
|
323
|
+
allowedAgents,
|
|
324
|
+
by: "Web 命令",
|
|
325
|
+
...(() => {
|
|
326
|
+
const sessionVisible = sessionVisibleTo(user);
|
|
327
|
+
return sessionVisible ? { sessionVisible } : {};
|
|
328
|
+
})(),
|
|
329
|
+
convVisible: (k) => canSeeKey(user, k),
|
|
330
|
+
allowGlobalAlways: isAdmin(user),
|
|
331
|
+
})
|
|
305
332
|
.then((reply) => ack(true, { reply, info: hub.info(key) }))
|
|
306
333
|
.catch((err) => ack(false, { error: err instanceof Error ? err.message : String(err) }));
|
|
307
334
|
return;
|
|
@@ -312,10 +339,15 @@ function handleClientMessage(client: Client, raw: string): void {
|
|
|
312
339
|
|
|
313
340
|
case "ix": {
|
|
314
341
|
const id = String(msg.id ?? "");
|
|
342
|
+
const target = interactions.get(id);
|
|
343
|
+
if (!target || !canSeeKey(user, target.convKey)) return ack(false, { error: "请求已失效" });
|
|
315
344
|
let ok = false;
|
|
316
345
|
let error: string | undefined;
|
|
317
346
|
switch (msg.op) {
|
|
318
347
|
case "decide":
|
|
348
|
+
if (msg.decision === "allow-always" && target.spec.kind === "approval" && target.spec.alwaysGlobal && !isAdmin(user)) {
|
|
349
|
+
return ack(false, { error: GLOBAL_ALWAYS_DENIED });
|
|
350
|
+
}
|
|
319
351
|
ok = !!interactions.decide(id, String(msg.decision) as Settlement, BY);
|
|
320
352
|
break;
|
|
321
353
|
case "select":
|
|
@@ -392,14 +424,15 @@ export async function startWeb(): Promise<void> {
|
|
|
392
424
|
server.on("upgrade", (req, socket, head) => {
|
|
393
425
|
try {
|
|
394
426
|
const url = new URL(req.url ?? "/", "http://local");
|
|
395
|
-
|
|
427
|
+
const user = url.pathname === "/ws" && sameOrigin(req) ? currentUser(req) : undefined;
|
|
428
|
+
if (!user) {
|
|
396
429
|
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
397
430
|
socket.destroy();
|
|
398
431
|
return;
|
|
399
432
|
}
|
|
400
433
|
const token = cookies(req)[COOKIE] ?? "";
|
|
401
434
|
wss!.handleUpgrade(req, socket, head, (ws) => {
|
|
402
|
-
const client: Client = { ws, subs: new Set(), alive: true, token, visible: false, viewing: null };
|
|
435
|
+
const client: Client = { ws, subs: new Set(), alive: true, token, user, visible: false, viewing: null };
|
|
403
436
|
clients.add(client);
|
|
404
437
|
ws.on("pong", () => (client.alive = true));
|
|
405
438
|
ws.on("message", (data) => {
|
package/src/cli/doctor.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { spawnSync } from "node:child_process";
|
|
|
3
3
|
import { config, loadConfig } from "../core/config.js";
|
|
4
4
|
import { FeishuClient } from "../channels/feishu/client.js";
|
|
5
5
|
import { configPath } from "../home.js";
|
|
6
|
+
import { hasUsers, listUsers } from "../core/users.js";
|
|
6
7
|
|
|
7
8
|
loadConfig();
|
|
8
9
|
const cfg = config();
|
|
@@ -36,4 +37,4 @@ for (const bot of cfg.feishu.bots) {
|
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
console.log("\nWeb");
|
|
39
|
-
console.log(` ${cfg.web.enabled ? `http://${cfg.web.host}:${cfg.web.port}` : "未启用"}${
|
|
40
|
+
console.log(` ${cfg.web.enabled ? `http://${cfg.web.host}:${cfg.web.port}` : "未启用"}${hasUsers() ? `(${listUsers().length} 个用户)` : "(还没设密码,首次打开需要启动日志里的设置码)"}`);
|
package/src/cli/passwd.ts
CHANGED
|
@@ -1,15 +1,32 @@
|
|
|
1
|
-
// agent-loft passwd:在终端里重置 Web
|
|
1
|
+
// agent-loft passwd [新密码] [--user 用户名]:在终端里重置 Web 登录密码(忘记密码时用)。
|
|
2
|
+
// 不指定用户就是最早的那个管理员;还一个用户都没有时,建一个叫 admin(或 --user 给的名字)的管理员。
|
|
3
|
+
// 被重置的用户所有已登录设备会下线。
|
|
2
4
|
import readline from "node:readline/promises";
|
|
3
5
|
import { loadConfig } from "../core/config.js";
|
|
4
|
-
import {
|
|
6
|
+
import { findUserByName, getUser, hasUsers, legacyOwnerId } from "../core/users.js";
|
|
7
|
+
import { setUserPassword, setupFirstAdmin } from "../channels/web/auth.js";
|
|
5
8
|
|
|
6
9
|
loadConfig();
|
|
7
|
-
const
|
|
8
|
-
|
|
10
|
+
const args = process.argv.slice(2);
|
|
11
|
+
const at = args.indexOf("--user");
|
|
12
|
+
const name = at >= 0 ? args[at + 1] : undefined;
|
|
13
|
+
if (at >= 0 && !name) throw new Error("--user 后面要跟用户名");
|
|
14
|
+
const positional = args.filter((_, i) => at < 0 || (i !== at && i !== at + 1));
|
|
15
|
+
|
|
16
|
+
const target = !hasUsers() ? undefined : name ? findUserByName(name) : getUser(legacyOwnerId());
|
|
17
|
+
if (hasUsers() && !target) throw new Error(name ? `没有用户「${name}」。用 agent-loft user list 看看有哪些` : "没有能登录的管理员,用 --user 指定用户");
|
|
18
|
+
|
|
19
|
+
let password = positional[0];
|
|
9
20
|
if (!password) {
|
|
10
21
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
11
|
-
password = await rl.question("
|
|
22
|
+
password = await rl.question(`${target ? `「${target.name}」` : "管理员"}的新 Web 登录密码(至少 8 位):`);
|
|
12
23
|
rl.close();
|
|
13
24
|
}
|
|
14
|
-
|
|
15
|
-
|
|
25
|
+
|
|
26
|
+
if (target) {
|
|
27
|
+
setUserPassword(target.id, password.trim());
|
|
28
|
+
console.log(`「${target.name}」的密码已更新,他已登录的设备需要重新登录。`);
|
|
29
|
+
} else {
|
|
30
|
+
const user = setupFirstAdmin(name ?? "admin", password.trim());
|
|
31
|
+
console.log(`已创建管理员「${user.name}」,用这个用户名和密码登录。`);
|
|
32
|
+
}
|
package/src/cli/user.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// agent-loft user …:在终端里管理 Web 登录用户(Web 上 设置 → 用户 里也能做)。
|
|
2
|
+
//
|
|
3
|
+
// list 列出用户
|
|
4
|
+
// add <用户名> [--admin] [密码] 新建用户(默认普通成员)
|
|
5
|
+
// passwd <用户名> [新密码] 重置密码,他的设备全部下线
|
|
6
|
+
// role <用户名> admin|member 改角色
|
|
7
|
+
// disable <用户名> / enable <用户名>
|
|
8
|
+
// remove <用户名>
|
|
9
|
+
import readline from "node:readline/promises";
|
|
10
|
+
import { loadConfig } from "../core/config.js";
|
|
11
|
+
import { createUser, findUserByName, legacyOwnerId, listUsers, removeUser } from "../core/users.js";
|
|
12
|
+
import { patchUser, revokeUserSessions } from "../channels/web/auth.js";
|
|
13
|
+
import { removeSubscriptionsOf } from "../channels/web/push.js";
|
|
14
|
+
|
|
15
|
+
loadConfig();
|
|
16
|
+
|
|
17
|
+
const USAGE = `用法:
|
|
18
|
+
agent-loft user list
|
|
19
|
+
agent-loft user add <用户名> [--admin] [密码]
|
|
20
|
+
agent-loft user passwd <用户名> [新密码]
|
|
21
|
+
agent-loft user role <用户名> admin|member
|
|
22
|
+
agent-loft user disable <用户名>
|
|
23
|
+
agent-loft user enable <用户名>
|
|
24
|
+
agent-loft user remove <用户名>
|
|
25
|
+
|
|
26
|
+
注意:多用户是「信任型」的 —— 所有 agent 都以同一个系统用户运行,能读到这台机器上的任何东西。
|
|
27
|
+
只把账号给你信任的人。`;
|
|
28
|
+
|
|
29
|
+
const [sub, ...rest] = process.argv.slice(2);
|
|
30
|
+
const flags = new Set(rest.filter((a) => a.startsWith("--")));
|
|
31
|
+
const args = rest.filter((a) => !a.startsWith("--"));
|
|
32
|
+
|
|
33
|
+
async function ask(prompt: string): Promise<string> {
|
|
34
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
35
|
+
const answer = await rl.question(prompt);
|
|
36
|
+
rl.close();
|
|
37
|
+
return answer.trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function need(name: string | undefined) {
|
|
41
|
+
if (!name) throw new Error(USAGE);
|
|
42
|
+
const user = findUserByName(name);
|
|
43
|
+
if (!user) throw new Error(`没有用户「${name}」`);
|
|
44
|
+
return user;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
switch (sub) {
|
|
48
|
+
case undefined:
|
|
49
|
+
case "list": {
|
|
50
|
+
const users = listUsers();
|
|
51
|
+
if (users.length === 0) console.log("还没有用户。打开网页用启动日志里的设置码建第一个管理员,或者运行 agent-loft passwd。");
|
|
52
|
+
for (const u of users) {
|
|
53
|
+
console.log(`${u.name}\t${u.role === "admin" ? "管理员" : "成员"}${u.disabled ? "\t已停用" : ""}`);
|
|
54
|
+
}
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
case "add": {
|
|
58
|
+
const name = args[0];
|
|
59
|
+
if (!name) throw new Error(USAGE);
|
|
60
|
+
const password = args[1] ?? (await ask(`「${name}」的登录密码(至少 8 位):`));
|
|
61
|
+
const user = createUser({ name, password, role: flags.has("--admin") ? "admin" : "member" });
|
|
62
|
+
console.log(`已创建${user.role === "admin" ? "管理员" : "成员"}「${user.name}」。`);
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
case "passwd": {
|
|
66
|
+
const user = need(args[0]);
|
|
67
|
+
const password = args[1] ?? (await ask(`「${user.name}」的新密码(至少 8 位):`));
|
|
68
|
+
patchUser(user.id, { password });
|
|
69
|
+
console.log(`「${user.name}」的密码已更新,他已登录的设备需要重新登录。`);
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
case "role": {
|
|
73
|
+
const user = need(args[0]);
|
|
74
|
+
const role = args[1];
|
|
75
|
+
if (role !== "admin" && role !== "member") throw new Error(USAGE);
|
|
76
|
+
patchUser(user.id, { role });
|
|
77
|
+
console.log(`「${user.name}」现在是${role === "admin" ? "管理员" : "成员"}。`);
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
case "disable":
|
|
81
|
+
case "enable": {
|
|
82
|
+
const user = need(args[0]);
|
|
83
|
+
patchUser(user.id, { disabled: sub === "disable" });
|
|
84
|
+
console.log(`「${user.name}」已${sub === "disable" ? "停用,设备已全部下线" : "启用"}。`);
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
case "remove": {
|
|
88
|
+
const user = need(args[0]);
|
|
89
|
+
// 升级前没记主人的推送订阅算在最早的管理员名下:删人之前先记下是谁
|
|
90
|
+
const legacy = legacyOwnerId();
|
|
91
|
+
removeUser(user.id);
|
|
92
|
+
removeSubscriptionsOf((owner) => (owner ?? legacy) === user.id);
|
|
93
|
+
revokeUserSessions(user.id);
|
|
94
|
+
console.log(`已删除「${user.name}」。他的会话还在,归管理员。`);
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
default:
|
|
98
|
+
throw new Error(USAGE);
|
|
99
|
+
}
|