ai_co_discuss 0.3.10 → 0.3.11
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/backend/remote_launch.py +96 -4
- package/backend/schema.sql +18 -0
- package/backend/server.py +2959 -2930
- package/docs/runner.md +4 -2
- package/package.json +1 -1
- package/runner/discussion_service.mjs +35 -9
- package/runner/free_runner.mjs +7 -3
- package/runner/remote_launch.mjs +133 -8
- package/runner/scheduler.mjs +2 -2
- package/web/app.js +68 -3
- package/web/modern.css +1 -1
- package/web/modern.js +4163 -4178
package/backend/remote_launch.py
CHANGED
|
@@ -5,6 +5,10 @@ connected browser would send to the local bridge; the paired computer claims it
|
|
|
5
5
|
sync, resolves the chosen project to its own directory and runs it. The Worker backend
|
|
6
6
|
implements the same contract in ``worker.mjs``; both operate on ``local_machines`` and
|
|
7
7
|
``remote_launches``.
|
|
8
|
+
|
|
9
|
+
Every other local-service operation travels as a machine command: the device encrypts it with the
|
|
10
|
+
computer's published P-256 key, the site only queues ciphertext, and the computer long-polls, runs it
|
|
11
|
+
through the same local entrypoint and returns an encrypted response.
|
|
8
12
|
"""
|
|
9
13
|
|
|
10
14
|
import hashlib
|
|
@@ -27,6 +31,13 @@ MACHINE_TOKEN = re.compile(r"[0-9a-f]{64}")
|
|
|
27
31
|
LAUNCH_ID = re.compile(r"[A-Za-z0-9_-]{1,80}")
|
|
28
32
|
PROJECT_ID = re.compile(r"[0-9a-f]{8,32}")
|
|
29
33
|
LAUNCH_STAGES = ("not_started", "unconfirmed", "created", "started")
|
|
34
|
+
COMMAND_ID = re.compile(r"[A-Za-z0-9_-]{16,80}")
|
|
35
|
+
COMMAND_TTL_SECONDS = 180
|
|
36
|
+
COMMAND_WAIT_MAX = 20
|
|
37
|
+
ENVELOPE_DATA_MAX = 512 * 1024
|
|
38
|
+
BASE64 = re.compile(r"[A-Za-z0-9+/]*={0,2}")
|
|
39
|
+
BASE64URL_COORDINATE = re.compile(r"[A-Za-z0-9_-]{43}")
|
|
40
|
+
IV_BASE64 = re.compile(r"[A-Za-z0-9+/]{16}")
|
|
30
41
|
|
|
31
42
|
|
|
32
43
|
def token_digest(token):
|
|
@@ -34,9 +45,13 @@ def token_digest(token):
|
|
|
34
45
|
return hashlib.sha256(token.encode()).hexdigest() if isinstance(token, str) and MACHINE_TOKEN.fullmatch(token) else ""
|
|
35
46
|
|
|
36
47
|
|
|
48
|
+
MACHINE_SELECT = "SELECT m.*, k.public_key FROM local_machines m LEFT JOIN machine_keys k ON k.machine_id=m.id"
|
|
49
|
+
|
|
50
|
+
|
|
37
51
|
def machine_view(row, now):
|
|
38
|
-
"""Public machine fields: projects by name and id,
|
|
52
|
+
"""Public machine fields: projects by name and id, which platforms it can wake, and its command key if any."""
|
|
39
53
|
return {"id": row["id"], "name": row["name"], "projects": json.loads(row["projects"]), "clients": json.loads(row["clients"]),
|
|
54
|
+
"public_key": json.loads(row["public_key"]) if row["public_key"] else None,
|
|
40
55
|
"seen_at": row["seen_at"], "online": now - row["seen_at"] <= MACHINE_ONLINE_SECONDS}
|
|
41
56
|
|
|
42
57
|
|
|
@@ -48,7 +63,7 @@ def launch_view(row):
|
|
|
48
63
|
|
|
49
64
|
def list_machines(connection, now):
|
|
50
65
|
"""Paired computers, most recently seen first."""
|
|
51
|
-
rows = connection.execute("
|
|
66
|
+
rows = connection.execute(f"{MACHINE_SELECT} ORDER BY m.seen_at DESC, m.created_at DESC").fetchall()
|
|
52
67
|
return {"machines": [machine_view(row, now) for row in rows]}
|
|
53
68
|
|
|
54
69
|
|
|
@@ -64,7 +79,7 @@ def pair_machine(connection, payload, now):
|
|
|
64
79
|
connection.execute("INSERT INTO local_machines (id,name,token_hash,created_at) VALUES (?,?,?,?) "
|
|
65
80
|
"ON CONFLICT(id) DO UPDATE SET name=excluded.name, token_hash=excluded.token_hash",
|
|
66
81
|
(machine_id, name, token_digest(token), now))
|
|
67
|
-
return machine_view(connection.execute("
|
|
82
|
+
return machine_view(connection.execute(f"{MACHINE_SELECT} WHERE m.id=?", (machine_id,)).fetchone(), now)
|
|
68
83
|
|
|
69
84
|
|
|
70
85
|
def require_machine(connection, machine_id, token):
|
|
@@ -98,13 +113,33 @@ def valid_clients(value):
|
|
|
98
113
|
return value
|
|
99
114
|
|
|
100
115
|
|
|
116
|
+
def valid_public_key(value):
|
|
117
|
+
"""An ECDH P-256 public key as JWK coordinates; anything else is rejected."""
|
|
118
|
+
if (not isinstance(value, dict) or value.get("kty") != "EC" or value.get("crv") != "P-256"
|
|
119
|
+
or not all(isinstance(value.get(axis), str) and BASE64URL_COORDINATE.fullmatch(value[axis]) for axis in ("x", "y"))):
|
|
120
|
+
raise ValueError("电脑公钥须为 P-256 JWK")
|
|
121
|
+
return {"kty": "EC", "crv": "P-256", "x": value["x"], "y": value["y"]}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def valid_envelope(value, with_key):
|
|
125
|
+
"""Ciphertext and IV, plus the sender's ephemeral key on requests; the site never reads the content."""
|
|
126
|
+
if (not isinstance(value, dict) or not isinstance(value.get("iv"), str) or not IV_BASE64.fullmatch(value["iv"])
|
|
127
|
+
or not isinstance(value.get("data"), str) or not 0 < len(value["data"]) <= ENVELOPE_DATA_MAX or not BASE64.fullmatch(value["data"])):
|
|
128
|
+
raise ValueError("加密命令格式无效")
|
|
129
|
+
return {"iv": value["iv"], "data": value["data"], **({"key": valid_public_key(value.get("key"))} if with_key else {})}
|
|
130
|
+
|
|
131
|
+
|
|
101
132
|
def machine_sync(connection, machine_id, token, payload, now):
|
|
102
|
-
"""Record the computer's projects and
|
|
133
|
+
"""Record the computer's projects, clients and command key, and hand it every launch it has not finished."""
|
|
103
134
|
require_machine(connection, machine_id, token)
|
|
104
135
|
projects, clients = valid_projects(payload.get("projects")), valid_clients(payload.get("clients"))
|
|
136
|
+
public_key = valid_public_key(payload["public_key"]) if "public_key" in payload else None
|
|
105
137
|
begin_write(connection)
|
|
106
138
|
connection.execute("UPDATE local_machines SET projects=?, clients=?, seen_at=? WHERE id=?",
|
|
107
139
|
(json.dumps(projects, ensure_ascii=False), json.dumps(clients, ensure_ascii=False), now, machine_id))
|
|
140
|
+
if public_key:
|
|
141
|
+
connection.execute("INSERT INTO machine_keys (machine_id,public_key) VALUES (?,?) "
|
|
142
|
+
"ON CONFLICT(machine_id) DO UPDATE SET public_key=excluded.public_key", (machine_id, json.dumps(public_key)))
|
|
108
143
|
rows = connection.execute("SELECT id,request FROM remote_launches WHERE machine_id=? AND status IN ('pending','claimed') ORDER BY created_at",
|
|
109
144
|
(machine_id,)).fetchall()
|
|
110
145
|
return {"launches": [{"id": row["id"], "request": json.loads(row["request"])} for row in rows]}
|
|
@@ -173,3 +208,60 @@ def remote_launch_status(connection, launch_id):
|
|
|
173
208
|
if not row:
|
|
174
209
|
raise LookupError("启动请求不存在")
|
|
175
210
|
return launch_view(row)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def submit_machine_command(connection, machine_id, payload, now):
|
|
214
|
+
"""Queue one encrypted local-service operation for the paired computer."""
|
|
215
|
+
command_id = payload.get("id")
|
|
216
|
+
if not isinstance(command_id, str) or not COMMAND_ID.fullmatch(command_id):
|
|
217
|
+
raise ValueError("命令编号无效")
|
|
218
|
+
envelope = valid_envelope(payload.get("envelope"), with_key=True)
|
|
219
|
+
begin_write(connection)
|
|
220
|
+
if not connection.execute("SELECT 1 FROM local_machines WHERE id=?", (machine_id,)).fetchone():
|
|
221
|
+
raise LookupError("这台电脑尚未连接网站")
|
|
222
|
+
if not connection.execute("SELECT 1 FROM machine_keys WHERE machine_id=?", (machine_id,)).fetchone():
|
|
223
|
+
raise FileExistsError("这台电脑的 codiscuss 版本过旧,请更新后重试")
|
|
224
|
+
if connection.execute("SELECT 1 FROM machine_commands WHERE id=?", (command_id,)).fetchone():
|
|
225
|
+
raise FileExistsError("命令编号已使用")
|
|
226
|
+
connection.execute("INSERT INTO machine_commands (id,machine_id,request,created_at,updated_at) VALUES (?,?,?,?,?)",
|
|
227
|
+
(command_id, machine_id, json.dumps(envelope), now, now))
|
|
228
|
+
return {"id": command_id, "status": "pending"}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def machine_command_status(connection, machine_id, command_id):
|
|
232
|
+
"""Report a command's progress; a finished command hands over its response once and is deleted."""
|
|
233
|
+
row = connection.execute("SELECT * FROM machine_commands WHERE id=? AND machine_id=?", (command_id, machine_id)).fetchone()
|
|
234
|
+
if not row:
|
|
235
|
+
raise LookupError("命令不存在或已过期")
|
|
236
|
+
if row["status"] != "done":
|
|
237
|
+
return {"id": command_id, "status": row["status"]}
|
|
238
|
+
begin_write(connection)
|
|
239
|
+
connection.execute("DELETE FROM machine_commands WHERE id=?", (command_id,))
|
|
240
|
+
return {"id": command_id, "status": "done", "response": json.loads(row["response"])}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def claim_machine_commands(connection, machine_id, token, now):
|
|
244
|
+
"""Drop expired commands and hand the computer every pending one, clearing the queued ciphertext."""
|
|
245
|
+
require_machine(connection, machine_id, token)
|
|
246
|
+
begin_write(connection)
|
|
247
|
+
connection.execute("DELETE FROM machine_commands WHERE machine_id=? AND created_at<?", (machine_id, now - COMMAND_TTL_SECONDS))
|
|
248
|
+
connection.execute("UPDATE local_machines SET seen_at=? WHERE id=?", (now, machine_id))
|
|
249
|
+
rows = connection.execute("SELECT id,request FROM machine_commands WHERE machine_id=? AND status='pending' ORDER BY created_at",
|
|
250
|
+
(machine_id,)).fetchall()
|
|
251
|
+
for row in rows:
|
|
252
|
+
connection.execute("UPDATE machine_commands SET status='claimed', request='{}', updated_at=? WHERE id=?", (now, row["id"]))
|
|
253
|
+
return {"commands": [{"id": row["id"], "envelope": json.loads(row["request"])} for row in rows]}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def finish_machine_command(connection, machine_id, command_id, token, payload, now):
|
|
257
|
+
"""Store the computer's encrypted response for the device that is waiting on it."""
|
|
258
|
+
require_machine(connection, machine_id, token)
|
|
259
|
+
envelope = valid_envelope(payload.get("envelope"), with_key=False)
|
|
260
|
+
begin_write(connection)
|
|
261
|
+
row = connection.execute("SELECT status FROM machine_commands WHERE id=? AND machine_id=?", (command_id, machine_id)).fetchone()
|
|
262
|
+
if not row:
|
|
263
|
+
raise LookupError("命令不存在或已过期")
|
|
264
|
+
if row["status"] != "claimed":
|
|
265
|
+
raise FileExistsError("命令已结束")
|
|
266
|
+
connection.execute("UPDATE machine_commands SET status='done', response=?, updated_at=? WHERE id=?", (json.dumps(envelope), now, command_id))
|
|
267
|
+
return {"id": command_id, "status": "done"}
|
package/backend/schema.sql
CHANGED
|
@@ -831,6 +831,24 @@ CREATE TABLE IF NOT EXISTS remote_launches (
|
|
|
831
831
|
updated_at INTEGER NOT NULL
|
|
832
832
|
);
|
|
833
833
|
CREATE INDEX IF NOT EXISTS remote_launches_machine ON remote_launches(machine_id, status);
|
|
834
|
+
-- The P-256 public key a paired computer publishes so other devices can encrypt commands only it can read.
|
|
835
|
+
CREATE TABLE IF NOT EXISTS machine_keys (
|
|
836
|
+
machine_id TEXT PRIMARY KEY,
|
|
837
|
+
public_key TEXT NOT NULL
|
|
838
|
+
);
|
|
839
|
+
-- A local-service operation relayed from a device without the local service. Request and response are end-to-end
|
|
840
|
+
-- encrypted between that device and the computer, so the site never holds keys, paths or results in plaintext;
|
|
841
|
+
-- the request is cleared once claimed and the row is deleted once read or expired.
|
|
842
|
+
CREATE TABLE IF NOT EXISTS machine_commands (
|
|
843
|
+
id TEXT PRIMARY KEY,
|
|
844
|
+
machine_id TEXT NOT NULL,
|
|
845
|
+
request TEXT NOT NULL DEFAULT '{}',
|
|
846
|
+
response TEXT NOT NULL DEFAULT '{}',
|
|
847
|
+
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','claimed','done')),
|
|
848
|
+
created_at INTEGER NOT NULL,
|
|
849
|
+
updated_at INTEGER NOT NULL
|
|
850
|
+
);
|
|
851
|
+
CREATE INDEX IF NOT EXISTS machine_commands_machine ON machine_commands(machine_id, status);
|
|
834
852
|
|
|
835
853
|
-- Normalize legacy online relay members and retired brainstorm rooms for the shared free-room model.
|
|
836
854
|
INSERT OR IGNORE INTO member_manual_sources(member_id,source)
|