agent-loft 0.2.22 → 0.2.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
- /** 登录态被撤销(退出、改密码、踢设备)时通知 WebSocket 那边立刻断开对应连接 */
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
- for (const [id, s] of Object.entries(sessions)) if (s.expiresAt < now) delete sessions[id];
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
- export function verifyPassword(password: string, stored: string): boolean {
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
- export function hasPassword(): boolean {
80
- return !!config().web.passwordHash;
85
+ /** 还一个用户都没有:页面上要先建管理员 */
86
+ export function needsSetup(): boolean {
87
+ return !hasUsers();
81
88
  }
82
89
 
83
- export function setPassword(password: string): void {
84
- if (password.length < 8) throw new Error("密码至少 8 位");
85
- const cfg = structuredClone(config());
86
- cfg.web.passwordHash = hashPassword(password);
87
- saveConfig(cfg);
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 (hasPassword()) return null;
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
- export function validateSession(token: string | undefined): WebSession | undefined {
131
- if (!token || !hasPassword()) return undefined;
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 s;
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 function listSessions(): (WebSession & { id: string })[] {
150
- return Object.entries(loadSessions()).map(([id, s]) => ({ id: id.slice(0, 12), ...s }));
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
- export function revokeSession(idPrefix: string): boolean {
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
  // 额度在「真要验密码的那一刻」同步扣掉,而不是先查后记:后者中间隔着读请求体的
@@ -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
- export function addSubscription(sub: unknown, userAgent: string): PushSubscriptionRecord {
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 async function pushToAll(payload: PushPayload): Promise<number> {
206
- const subs = [...load().subscriptions];
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 { authed, handleApi, takeUploads } from "./api.js";
19
- import { authEvents, ensureSetupToken, validateSession } from "./auth.js";
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
- /** 登录态已失效的连接:断开。客户端收到 4401 会回到登录页 */
146
- function checkClient(client: Client): boolean {
147
- if (validateSession(client.token)) return true;
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 false;
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() && !watching(key)) void pushToAll({ ...notice, url: `/#${encodeURIComponent(key)}` });
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
- if (!checkClient(client)) return;
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, { allowedAgents, by: "Web 命令" })
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
- if (url.pathname !== "/ws" || !sameOrigin(req) || !authed(req)) {
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}` : "未启用"}${cfg.web.passwordHash ? "" : "(还没设密码,首次打开需要启动日志里的设置码)"}`);
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 { setPassword } from "../channels/web/auth.js";
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 fromArg = process.argv[2];
8
- let password = fromArg;
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("新的 Web 登录密码(至少 8 位):");
22
+ password = await rl.question(`${target ? `「${target.name}」` : "管理员"}的新 Web 登录密码(至少 8 位):`);
12
23
  rl.close();
13
24
  }
14
- setPassword(password.trim());
15
- console.log("密码已更新,所有已登录的设备需要重新登录。");
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
+ }
@@ -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
+ }