ai_co_discuss 0.3.46 → 0.3.49
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/.agents/skills/discuss-host/SKILL.md +1 -0
- package/.agents/skills/discuss-host/references/discussion.md +113 -113
- package/.agents/skills/discuss-host/references/free.md +36 -34
- package/.agents/skills/discuss-host/references/goal.md +35 -0
- package/.agents/skills/discuss-participant/SKILL.md +1 -0
- package/.agents/skills/workflow-creator/SKILL.md +2 -2
- package/README.md +3 -1
- package/backend/admin.py +104 -35
- package/backend/free_room.py +2170 -2136
- package/backend/goal_room.py +358 -0
- package/backend/mode_room.py +105 -0
- package/backend/schema.sql +1593 -1510
- package/backend/server.py +2636 -2555
- package/backend/session_room.py +1180 -1148
- package/docs/THIRD_PARTY_NOTICES.md +1293 -1264
- package/docs/deployment.md +5 -1
- package/docs/en/workflow.md +15 -1
- package/docs/runner.md +28 -7
- package/docs/workflow.md +15 -1
- package/package.json +3 -2
- package/runner/adapters/api.mjs +333 -313
- package/runner/adapters/claude.mjs +12 -16
- package/runner/admin_account.mjs +12 -19
- package/runner/device_auth.mjs +369 -0
- package/runner/discuss.py +1208 -1125
- package/runner/discussion_launch.mjs +107 -12
- package/runner/discussion_service.mjs +25 -1
- package/runner/expert_prompts.mjs +132 -132
- package/runner/free_runner.mjs +982 -910
- package/runner/pinyin_table.mjs +406 -0
- package/runner/project_files.mjs +52 -3
- package/runner/relay_channel.mjs +51 -7
- package/runner/remote_launch.mjs +221 -235
- package/runner/remote_policy.mjs +156 -0
- package/runner/scheduler.mjs +459 -429
- package/runner/stt.mjs +2 -2
- package/runner/tts.mjs +74 -113
- package/runner/tts_providers.mjs +205 -0
- package/runner/webauthn.mjs +155 -0
- package/runner/workflows.mjs +371 -348
- package/web/app.js +355 -51
- package/web/modern.css +1 -1
- package/web/modern.js +5764 -3816
package/backend/admin.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Single site administrator account: registration, login sessions and profile.
|
|
2
2
|
|
|
3
3
|
Passwords and session tokens are stored only as hashes. The Worker backend implements
|
|
4
|
-
the same contract in ``worker.mjs``; both operate on ``site_admin`` and ``
|
|
4
|
+
the same contract in ``worker.mjs``; both operate on ``site_admin``, ``admin_logins`` and ``admin_attempts``.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import hashlib
|
|
@@ -12,8 +12,18 @@ import time
|
|
|
12
12
|
from .runtime import begin_write
|
|
13
13
|
|
|
14
14
|
PBKDF2_ITERATIONS = 100000
|
|
15
|
-
|
|
15
|
+
# A login ends after a week unused or a month in all; same rules as worker.mjs.
|
|
16
|
+
SESSION_IDLE_SECONDS = 7 * 86400
|
|
17
|
+
SESSION_MAX_SECONDS = 30 * 86400
|
|
18
|
+
SESSION_SEEN_SECONDS = 3600
|
|
19
|
+
SESSION_LABEL_LIMIT = 160
|
|
20
|
+
# Each run of five wrong passwords from one address locks it, twice as long each time, up to a day; all addresses
|
|
21
|
+
# together are capped per hour.
|
|
22
|
+
FAILURES_PER_LOCK = 5
|
|
16
23
|
LOCK_SECONDS = 300
|
|
24
|
+
LOCK_MAX_SECONDS = 86400
|
|
25
|
+
GLOBAL_FAILURES = 500
|
|
26
|
+
GLOBAL_WINDOW = 3600
|
|
17
27
|
USERNAME_MAX = 30
|
|
18
28
|
PASSWORD_MIN = 8
|
|
19
29
|
PASSWORD_MAX = 128
|
|
@@ -87,10 +97,19 @@ def profile_of(row):
|
|
|
87
97
|
return {"username": row["username"], "avatar": row["avatar"]} if row else None
|
|
88
98
|
|
|
89
99
|
|
|
90
|
-
def
|
|
91
|
-
"""
|
|
100
|
+
def live_session(connection, token, now=None):
|
|
101
|
+
"""Return the login row for a token that is neither idle too long nor too old, or None."""
|
|
92
102
|
hashed = session_hash(token)
|
|
93
|
-
|
|
103
|
+
if not hashed:
|
|
104
|
+
return None
|
|
105
|
+
now = int(time.time()) if now is None else now
|
|
106
|
+
return connection.execute("SELECT * FROM admin_logins WHERE token_hash=? AND last_seen>=? AND created_at>=?",
|
|
107
|
+
(hashed, now - SESSION_IDLE_SECONDS, now - SESSION_MAX_SECONDS)).fetchone()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def session_valid(connection, token):
|
|
111
|
+
"""Whether the token belongs to a live login; reading never writes, the status check marks last use."""
|
|
112
|
+
return live_session(connection, token) is not None
|
|
94
113
|
|
|
95
114
|
|
|
96
115
|
def require_session(connection, token):
|
|
@@ -107,18 +126,25 @@ def admin_status(connection, token, setup_available, local=False):
|
|
|
107
126
|
row = admin_row(connection)
|
|
108
127
|
if local:
|
|
109
128
|
return {"local": True, "registered": True, "setup_available": False, "logged_in": True, "profile": profile_of(row)}
|
|
129
|
+
now = int(time.time())
|
|
130
|
+
session = live_session(connection, token, now) if row else None
|
|
131
|
+
# Pages ask for this status when they open, which is when a login counts as used.
|
|
132
|
+
if session and now - session["last_seen"] > SESSION_SEEN_SECONDS:
|
|
133
|
+
connection.execute("UPDATE admin_logins SET last_seen=? WHERE token_hash=?", (now, session["token_hash"]))
|
|
110
134
|
return {"registered": bool(row), "setup_available": bool(setup_available and not row),
|
|
111
|
-
"logged_in":
|
|
135
|
+
"logged_in": session is not None, "profile": profile_of(row)}
|
|
112
136
|
|
|
113
137
|
|
|
114
|
-
def open_session(connection, now):
|
|
115
|
-
"""Create a
|
|
138
|
+
def open_session(connection, now, label=""):
|
|
139
|
+
"""Create a login labelled with the browser that asked for it and return its raw token, shown only once."""
|
|
116
140
|
token = secrets.token_hex(32)
|
|
117
|
-
connection.execute("
|
|
141
|
+
connection.execute("DELETE FROM admin_logins WHERE last_seen<? OR created_at<?", (now - SESSION_IDLE_SECONDS, now - SESSION_MAX_SECONDS))
|
|
142
|
+
connection.execute("INSERT INTO admin_logins (token_hash,label,created_at,last_seen) VALUES (?,?,?,?)",
|
|
143
|
+
(session_hash(token), str(label or "")[:SESSION_LABEL_LIMIT], now, now))
|
|
118
144
|
return token
|
|
119
145
|
|
|
120
146
|
|
|
121
|
-
def register_admin(connection, payload, setup_key, now):
|
|
147
|
+
def register_admin(connection, payload, setup_key, now, label=""):
|
|
122
148
|
"""Create the administrator when the install token matches; only the first call succeeds."""
|
|
123
149
|
begin_write(connection)
|
|
124
150
|
if admin_row(connection):
|
|
@@ -135,42 +161,85 @@ def register_admin(connection, payload, setup_key, now):
|
|
|
135
161
|
connection.execute("INSERT INTO site_admin (id,username,password_hash,created_at) VALUES (1,?,?,?)",
|
|
136
162
|
(username, hash_password(password), now))
|
|
137
163
|
connection.execute("DELETE FROM admin_setup")
|
|
138
|
-
return {"token": open_session(connection, now), "profile": {"username": username, "avatar": ""}}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
def
|
|
142
|
-
"""
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
164
|
+
return {"token": open_session(connection, now, label), "profile": {"username": username, "avatar": ""}}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def check_password(connection, row, password, source, now):
|
|
168
|
+
"""Verify a password with backoff per address and a cap across all addresses; failures are committed at once."""
|
|
169
|
+
attempts = connection.execute("SELECT * FROM admin_attempts WHERE source=?", (source,)).fetchone()
|
|
170
|
+
every = connection.execute("SELECT * FROM admin_attempts WHERE source='*'").fetchone()
|
|
171
|
+
for limit in (attempts, every):
|
|
172
|
+
if limit and limit["locked_until"] > now:
|
|
173
|
+
raise AdminError(f"尝试次数过多,请 {limit['locked_until'] - now} 秒后再试", 429)
|
|
174
|
+
if isinstance(password, str) and verify_password(password, row["password_hash"]):
|
|
175
|
+
connection.execute("DELETE FROM admin_attempts WHERE source=?", (source,))
|
|
176
|
+
return
|
|
177
|
+
failed = (attempts["failed"] if attempts else 0) + 1
|
|
178
|
+
lock = 0 if failed % FAILURES_PER_LOCK else min(LOCK_SECONDS * 2 ** (failed // FAILURES_PER_LOCK - 1), LOCK_MAX_SECONDS)
|
|
179
|
+
total = every["failed"] + 1 if every and now - every["updated_at"] <= GLOBAL_WINDOW else 1
|
|
180
|
+
connection.execute("INSERT OR REPLACE INTO admin_attempts (source,failed,locked_until,updated_at) VALUES (?,?,?,?)",
|
|
181
|
+
(source, failed, now + lock if lock else 0, now))
|
|
182
|
+
connection.execute("INSERT OR REPLACE INTO admin_attempts (source,failed,locked_until,updated_at) VALUES ('*',?,?,?)",
|
|
183
|
+
(0 if total >= GLOBAL_FAILURES else total, now + GLOBAL_WINDOW if total >= GLOBAL_FAILURES else 0,
|
|
184
|
+
now if total == 1 else every["updated_at"]))
|
|
147
185
|
connection.commit()
|
|
186
|
+
raise AdminError("用户名或密码错误", 401)
|
|
148
187
|
|
|
149
188
|
|
|
150
|
-
def
|
|
151
|
-
"""
|
|
152
|
-
if row["locked_until"] > now:
|
|
153
|
-
raise AdminError(f"尝试次数过多,请 {row['locked_until'] - now} 秒后再试", 429)
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
def login_admin(connection, payload, now):
|
|
157
|
-
"""Verify the account and open a session; failures are generic and rate limited."""
|
|
189
|
+
def login_admin(connection, payload, now, source="unknown", label=""):
|
|
190
|
+
"""Verify the account and open a login; failures are generic and rate limited per address."""
|
|
158
191
|
begin_write(connection)
|
|
159
192
|
row = admin_row(connection)
|
|
160
193
|
username, password = payload.get("username"), payload.get("password")
|
|
161
194
|
if not row or not isinstance(username, str) or not isinstance(password, str):
|
|
162
195
|
raise AdminError("用户名或密码错误", 401)
|
|
163
|
-
|
|
164
|
-
if username.strip().lower()
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
196
|
+
# The username is checked after the lock so a wrong name and a wrong password are indistinguishable.
|
|
197
|
+
check_password(connection, row, password if username.strip().lower() == row["username"].lower() else None, source, now)
|
|
198
|
+
return {"token": open_session(connection, now, label), "profile": profile_of(row)}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def list_sessions(connection, token, now):
|
|
202
|
+
"""List live logins with their browser label; the id is a hash prefix, useless for signing in."""
|
|
203
|
+
current = live_session(connection, token, now)
|
|
204
|
+
if not current:
|
|
205
|
+
raise AdminError("需要登录管理员账号", 401)
|
|
206
|
+
rows = connection.execute("SELECT * FROM admin_logins WHERE last_seen>=? AND created_at>=? ORDER BY last_seen DESC",
|
|
207
|
+
(now - SESSION_IDLE_SECONDS, now - SESSION_MAX_SECONDS)).fetchall()
|
|
208
|
+
return {"sessions": [{"id": row["token_hash"][:16], "label": row["label"], "created_at": row["created_at"], "last_seen": row["last_seen"],
|
|
209
|
+
"current": row["token_hash"] == current["token_hash"]} for row in rows]}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def revoke_sessions(connection, token, payload, now):
|
|
213
|
+
"""End one other login by id, or every login but this one."""
|
|
214
|
+
current = live_session(connection, token, now)
|
|
215
|
+
if not current:
|
|
216
|
+
raise AdminError("需要登录管理员账号", 401)
|
|
217
|
+
if payload.get("others") is True:
|
|
218
|
+
connection.execute("DELETE FROM admin_logins WHERE token_hash<>?", (current["token_hash"],))
|
|
219
|
+
return {"ok": True}
|
|
220
|
+
target = payload.get("id")
|
|
221
|
+
if not isinstance(target, str) or len(target) != 16 or any(char not in "0123456789abcdef" for char in target):
|
|
222
|
+
raise ValueError("须提供要退出的登录 id,或 others: true")
|
|
223
|
+
connection.execute("DELETE FROM admin_logins WHERE substr(token_hash,1,16)=? AND token_hash<>?", (target, current["token_hash"]))
|
|
224
|
+
return {"ok": True}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def change_password(connection, token, payload, now, source="unknown"):
|
|
228
|
+
"""Change the password after proving the current one; every other login ends."""
|
|
229
|
+
begin_write(connection)
|
|
230
|
+
current = live_session(connection, token, now)
|
|
231
|
+
if not current:
|
|
232
|
+
raise AdminError("需要登录管理员账号", 401)
|
|
233
|
+
password = valid_password(payload.get("password"))
|
|
234
|
+
check_password(connection, admin_row(connection), payload.get("current_password"), source, now)
|
|
235
|
+
connection.execute("UPDATE site_admin SET password_hash=? WHERE id=1", (hash_password(password),))
|
|
236
|
+
connection.execute("DELETE FROM admin_logins WHERE token_hash<>?", (current["token_hash"],))
|
|
237
|
+
return {"ok": True}
|
|
169
238
|
|
|
170
239
|
|
|
171
240
|
def logout_admin(connection, token):
|
|
172
|
-
"""Remove the current
|
|
173
|
-
connection.execute("DELETE FROM
|
|
241
|
+
"""Remove the current login; succeeds even without one."""
|
|
242
|
+
connection.execute("DELETE FROM admin_logins WHERE token_hash=?", (session_hash(token),))
|
|
174
243
|
return {"ok": True}
|
|
175
244
|
|
|
176
245
|
|