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/runner/remote_launch.mjs
CHANGED
|
@@ -1,235 +1,221 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Run what other devices relay through a paired website: room launches, and every other local-service operation as an
|
|
3
|
-
* end-to-end encrypted command that only this computer can read; directories and credentials stay here.
|
|
4
|
-
*/
|
|
5
|
-
import { spawn } from 'node:child_process';
|
|
6
|
-
import { generateKeyPairSync, randomBytes, randomUUID } from 'node:crypto';
|
|
7
|
-
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
8
|
-
import { hostname } from 'node:os';
|
|
9
|
-
import { join } from 'node:path';
|
|
10
|
-
import { fileURLToPath } from 'node:url';
|
|
11
|
-
|
|
12
|
-
import { adapter_profile } from './adapters/profiles.mjs';
|
|
13
|
-
import { atomic_json } from './files.mjs';
|
|
14
|
-
import { saved_api_keys } from './discussion_api.mjs';
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
'room-member-model', 'room-profiles', 'room-files', 'room-restore', 'room-fork', 'room-workflow', '
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
export async function relay_commands(home, { run = run_launch_command, log = () => {}, wait = COMMAND_WAIT_SECONDS, signal, running = new Set(), on_failure = () => {}, skip = new Set() } = {}) {
|
|
223
|
-
// Keys are created by the sync, which publishes them; a site without one yet has nothing to claim. A site reached
|
|
224
|
-
// through its live relay channel needs no D1 polling.
|
|
225
|
-
await Promise.all(Object.entries(machine_links(home).sites).filter(([site, link]) => link.private_key && !skip.has(site)).map(async ([site, link]) => {
|
|
226
|
-
let commands;
|
|
227
|
-
try { ({ commands } = await machine_request(site, link, '/commands/claim', { wait }, { timeout: wait * 1000 + COMMAND_REQUEST_MARGIN_MS, signal })); }
|
|
228
|
-
catch (error) { if (!signal?.aborted) { log(`${site}: 领取远程命令失败:${error.message}`); on_failure(); } return; }
|
|
229
|
-
for (const command of commands) {
|
|
230
|
-
const task = answer_command(site, link, command, run, log).finally(() => running.delete(task));
|
|
231
|
-
running.add(task);
|
|
232
|
-
}
|
|
233
|
-
}));
|
|
234
|
-
return running;
|
|
235
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Run what other devices relay through a paired website: room launches, and every other local-service operation as an
|
|
3
|
+
* end-to-end encrypted command that only this computer can read; directories and credentials stay here.
|
|
4
|
+
*/
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { generateKeyPairSync, randomBytes, randomUUID } from 'node:crypto';
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { hostname } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
12
|
+
import { adapter_profile } from './adapters/profiles.mjs';
|
|
13
|
+
import { atomic_json } from './files.mjs';
|
|
14
|
+
import { saved_api_keys } from './discussion_api.mjs';
|
|
15
|
+
import { launch_projects, launch_settings } from './discussion_launch.mjs';
|
|
16
|
+
import { claim_relayed_command } from './remote_policy.mjs';
|
|
17
|
+
import { audit, DEVICE_ACTIONS, device_command, open_signed_request } from './device_auth.mjs';
|
|
18
|
+
|
|
19
|
+
const MACHINE_TOKEN_BYTES = 32;
|
|
20
|
+
const MACHINE_NAME_MAX = 60;
|
|
21
|
+
const SYNC_TIMEOUT_MS = 10000;
|
|
22
|
+
const LINKS_FILE = 'machine-links.json';
|
|
23
|
+
// The website holds a claim this long when nothing is queued, so a waiting device is answered at once.
|
|
24
|
+
export const COMMAND_WAIT_SECONDS = 20;
|
|
25
|
+
const COMMAND_REQUEST_MARGIN_MS = 10000;
|
|
26
|
+
// Matches LOCAL_LAUNCH_TIMEOUT in backend/server.py, which runs the same entrypoint for the local website.
|
|
27
|
+
const COMMAND_TIMEOUT_MS = 60000;
|
|
28
|
+
const RELAY_CIPHER_INFO = 'codiscuss-relay-v1';
|
|
29
|
+
const LAUNCH_SCRIPT = fileURLToPath(new URL('./discussion_launch.mjs', import.meta.url));
|
|
30
|
+
// What a connected browser may ask the local bridge for, minus the folder dialog nobody would see and pairing itself.
|
|
31
|
+
const RELAYED_ACTIONS = new Set(['settings', 'launch', 'room-models', 'room-member', 'room-member-access', 'room-member-allow', 'room-member-adapter',
|
|
32
|
+
'room-member-model', 'room-profiles', 'room-files', 'room-restore', 'room-fork', 'models', 'api-key', 'api-check', 'scheduler-check', 'tts-settings', 'tts', 'stt', 'projects', 'presets', 'workflows', 'room-workflow', 'browse-directory']);
|
|
33
|
+
export const ROOM_ACTIONS = new Set(['launch', 'room-models', 'room-member', 'room-member-access', 'room-member-allow', 'room-member-adapter',
|
|
34
|
+
'room-member-model', 'room-profiles', 'room-files', 'room-restore', 'room-fork', 'room-workflow', 'workflows']);
|
|
35
|
+
|
|
36
|
+
/** Read every site this computer is paired with. */
|
|
37
|
+
export function machine_links(home) {
|
|
38
|
+
const path = join(home, LINKS_FILE);
|
|
39
|
+
return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : { sites: {} };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A P-256 key pair for relayed commands; the private half never leaves this computer. */
|
|
43
|
+
function command_keys() {
|
|
44
|
+
const { publicKey, privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
|
45
|
+
const { kty, crv, x, y } = publicKey.export({ format: 'jwk' });
|
|
46
|
+
return { public_key: { kty, crv, x, y }, private_key: privateKey.export({ format: 'jwk' }) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Give every paired site a command key pair, kept beside its token; older pairings gain one on first use. */
|
|
50
|
+
function keyed_links(home) {
|
|
51
|
+
const links = machine_links(home);
|
|
52
|
+
const missing = Object.values(links.sites).filter(link => !link.private_key);
|
|
53
|
+
for (const link of missing) Object.assign(link, command_keys());
|
|
54
|
+
if (missing.length) atomic_json(join(home, LINKS_FILE), links);
|
|
55
|
+
return links;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Return this computer's identity for one site, creating it on first use; the browser then pairs it as administrator. */
|
|
59
|
+
export function machine_link(home, site) {
|
|
60
|
+
const origin = new URL(site).origin;
|
|
61
|
+
const links = machine_links(home);
|
|
62
|
+
links.sites[origin] ||= { id: randomUUID().replaceAll('-', ''), token: randomBytes(MACHINE_TOKEN_BYTES).toString('hex'),
|
|
63
|
+
name: (hostname() || '本机').slice(0, MACHINE_NAME_MAX), ...command_keys() };
|
|
64
|
+
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
65
|
+
atomic_json(join(home, LINKS_FILE), links);
|
|
66
|
+
// The browser forwards this to the website, so the private key stays out of it.
|
|
67
|
+
const { id, token, name } = links.sites[origin];
|
|
68
|
+
return { id, token, name };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Forget a site's pairing so it can no longer hand launches to this computer. */
|
|
72
|
+
export function unlink_machine(home, site) {
|
|
73
|
+
const links = machine_links(home);
|
|
74
|
+
if (!links.sites[site]) return;
|
|
75
|
+
delete links.sites[site];
|
|
76
|
+
atomic_json(join(home, LINKS_FILE), links);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Resolve the API key saved for a profile's base URL, as the connected browser does. */
|
|
80
|
+
function api_key_ref(profile, keys) {
|
|
81
|
+
if (profile.api_key_ref || !profile.base_url) return profile.api_key_ref;
|
|
82
|
+
try { return keys[new URL(profile.base_url).href.replace(/\/+$/, '')]; } catch { return undefined; }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Platforms this computer can wake without further setup, and the client each uses. */
|
|
86
|
+
export function ready_clients(home) {
|
|
87
|
+
const keys = saved_api_keys(home);
|
|
88
|
+
return Object.fromEntries(Object.entries(launch_settings(home).participants).filter(([, profile]) => {
|
|
89
|
+
const facts = adapter_profile(profile.adapter);
|
|
90
|
+
if (!facts) return false;
|
|
91
|
+
// A model API needs its endpoint and key; other software needs its installed location.
|
|
92
|
+
return facts.kind === 'api' ? Boolean(profile.base_url && profile.api_protocol && profile.model && api_key_ref(profile, keys))
|
|
93
|
+
: Boolean(profile[facts.location]);
|
|
94
|
+
}).map(([name, profile]) => [name, profile.adapter]));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function machine_request(site, link, path, body, { timeout = SYNC_TIMEOUT_MS, signal } = {}) {
|
|
98
|
+
const response = await fetch(`${site}/api/machines/${link.id}${path}`, { method: 'POST', redirect: 'error',
|
|
99
|
+
signal: AbortSignal.any([AbortSignal.timeout(timeout), ...(signal ? [signal] : [])]),
|
|
100
|
+
headers: { 'Content-Type': 'application/json', 'X-Machine-Token': link.token }, body: JSON.stringify(body) });
|
|
101
|
+
const result = await response.json();
|
|
102
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}: ${result.error || '请求失败'}`);
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Publish projects, clients and this computer's key to each paired site. Launch tasks a site returns are never run:
|
|
108
|
+
* every remote operation must arrive as a device-signed command, and a site's own answer proves nothing.
|
|
109
|
+
*/
|
|
110
|
+
export async function sync_remote_launches(home, { log = () => {} } = {}) {
|
|
111
|
+
const projects = launch_projects(home).projects.filter(project => project.id).map(({ id, name }) => ({ id, name }));
|
|
112
|
+
const clients = ready_clients(home);
|
|
113
|
+
for (const [site, link] of Object.entries(keyed_links(home).sites)) {
|
|
114
|
+
let launches;
|
|
115
|
+
try { ({ launches } = await machine_request(site, link, '/sync', { projects, clients, public_key: link.public_key })); }
|
|
116
|
+
catch (error) { log(`${site}: 同步电脑状态失败:${error.message}`); continue; }
|
|
117
|
+
if (launches?.length) log(`${site}: 网站返回了 ${launches.length} 个旧格式启动任务,已忽略;远程启动须由已配对设备签名`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The AES-GCM key shared with one sending device, derived from its ephemeral key and this computer's private key. */
|
|
122
|
+
export async function relay_cipher(private_jwk, public_jwk) {
|
|
123
|
+
const curve = { name: 'ECDH', namedCurve: 'P-256' };
|
|
124
|
+
const own = await crypto.subtle.importKey('jwk', private_jwk, curve, false, ['deriveBits']);
|
|
125
|
+
const peer = await crypto.subtle.importKey('jwk', public_jwk, curve, false, []);
|
|
126
|
+
const material = await crypto.subtle.importKey('raw', await crypto.subtle.deriveBits({ name: 'ECDH', public: peer }, own, 256), 'HKDF', false, ['deriveKey']);
|
|
127
|
+
return crypto.subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(), info: new TextEncoder().encode(RELAY_CIPHER_INFO) },
|
|
128
|
+
material, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Encrypt a value bound to its command, so the site cannot swap one command's content into another. */
|
|
132
|
+
export async function seal(key, value, label) {
|
|
133
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
134
|
+
const data = await crypto.subtle.encrypt({ name: 'AES-GCM', iv, additionalData: new TextEncoder().encode(label) }, key, new TextEncoder().encode(JSON.stringify(value)));
|
|
135
|
+
return { iv: Buffer.from(iv).toString('base64'), data: Buffer.from(data).toString('base64') };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Decrypt a value sealed for the same command label. */
|
|
139
|
+
export async function unseal(key, envelope, label) {
|
|
140
|
+
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: Buffer.from(envelope.iv, 'base64'), additionalData: new TextEncoder().encode(label) },
|
|
141
|
+
key, Buffer.from(envelope.data, 'base64'));
|
|
142
|
+
return JSON.parse(new TextDecoder().decode(plain));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Turn a relayed action into the local entrypoint call the local website makes, pinned to the relaying site. */
|
|
146
|
+
export function relayed_command(site, action, payload) {
|
|
147
|
+
if (!RELAYED_ACTIONS.has(action)) throw new Error('该操作不能经其他设备执行');
|
|
148
|
+
if (payload === undefined ? action !== 'settings' : !payload || typeof payload !== 'object' || Array.isArray(payload)) throw new Error('命令内容无效');
|
|
149
|
+
let input = payload;
|
|
150
|
+
if (ROOM_ACTIONS.has(action)) {
|
|
151
|
+
const { access_file, ...rest } = payload;
|
|
152
|
+
input = { ...rest, site, entry: 'web' };
|
|
153
|
+
}
|
|
154
|
+
// A path names a folder on the computer itself; another device sends the files it opened.
|
|
155
|
+
if (action === 'workflows' && payload.folder) throw new Error('其他设备请选择 zip 或文件夹导入工作流');
|
|
156
|
+
return { command: action === 'launch' ? 'start' : action, input, browser: action === 'launch' };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Run the local entrypoint as the local website does, under the limits for other devices, and answer in its HTTP shape. */
|
|
160
|
+
export function run_launch_command({ command, input, browser, device }) {
|
|
161
|
+
return new Promise(resolve => {
|
|
162
|
+
const args = [LAUNCH_SCRIPT, command, '--remote', ...(device ? ['--device', device] : []), ...(browser ? ['--browser'] : []),
|
|
163
|
+
...(input === undefined ? [] : ['--stdin'])];
|
|
164
|
+
const child = spawn(process.execPath, args, { windowsHide: true });
|
|
165
|
+
let stdout = '', stderr = '', timed_out = false;
|
|
166
|
+
child.stdout.setEncoding('utf8').on('data', chunk => { stdout += chunk; });
|
|
167
|
+
child.stderr.setEncoding('utf8').on('data', chunk => { stderr += chunk; });
|
|
168
|
+
const timer = setTimeout(() => { timed_out = true; child.kill(); }, COMMAND_TIMEOUT_MS);
|
|
169
|
+
child.on('error', error => { clearTimeout(timer); resolve({ status: 400, body: { error: error.message } }); });
|
|
170
|
+
child.on('close', code => {
|
|
171
|
+
clearTimeout(timer);
|
|
172
|
+
if (timed_out) return resolve({ status: 400, body: { error: '启动结果未确认,请核对房间状态后沿用原配置重试' } });
|
|
173
|
+
try { resolve(code ? { status: 400, body: JSON.parse(stderr.trim()) } : { status: 200, body: JSON.parse(stdout) }); }
|
|
174
|
+
catch { resolve({ status: 400, body: { error: stderr.trim() || '本机操作失败' } }); }
|
|
175
|
+
});
|
|
176
|
+
child.stdin.end(input === undefined ? '' : JSON.stringify(input));
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Decrypt one claimed command, run it, and return the encrypted answer to the device waiting on it. */
|
|
181
|
+
async function answer_command(home, site, link, { id, envelope }, run, log) {
|
|
182
|
+
let key, sealed;
|
|
183
|
+
try {
|
|
184
|
+
key = await relay_cipher(link.private_key, envelope.key);
|
|
185
|
+
sealed = await unseal(key, envelope, id);
|
|
186
|
+
} catch { return log(`${site}: 远程命令 ${id} 无法解密,已忽略`); }
|
|
187
|
+
let response;
|
|
188
|
+
try {
|
|
189
|
+
const { request, device_id } = await open_signed_request(home, id, sealed, link.id);
|
|
190
|
+
claim_relayed_command(home, id, request.sent_at);
|
|
191
|
+
response = DEVICE_ACTIONS.has(request.action) ? await device_command(home, { site, machine_id: link.id, device_id }, request.action, request.payload)
|
|
192
|
+
: await run({ ...relayed_command(site, request.action, request.payload), device: device_id });
|
|
193
|
+
if (!DEVICE_ACTIONS.has(request.action)) audit(home, { event: 'command', action: request.action, route: '', device: device_id, status: response.status });
|
|
194
|
+
}
|
|
195
|
+
catch (error) { response = { status: 400, body: { error: error.message, ...(error.need ? { need: error.need } : {}) } }; }
|
|
196
|
+
try { await machine_request(site, link, `/commands/${encodeURIComponent(id)}`, { envelope: await seal(key, response, `${id}:response`) }); }
|
|
197
|
+
catch (error) { log(`${site}: 远程命令 ${id} 回传失败:${error.message}`); }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Wait on every paired site for relayed commands and start each one; resolves once the claims return, with the commands
|
|
202
|
+
* still running in `running` so a slow model list never holds up the next claim. `on_failure` hears a claim that failed,
|
|
203
|
+
* so the caller can wait before retrying while a successful claim is followed at once.
|
|
204
|
+
*/
|
|
205
|
+
export async function relay_commands(home, { run = run_launch_command, log = () => {}, wait = COMMAND_WAIT_SECONDS, signal, running = new Set(), on_failure = () => {}, skip = new Set() } = {}) {
|
|
206
|
+
// Keys are created by the sync, which publishes them; a site without one yet has nothing to claim. A site reached
|
|
207
|
+
// through its live relay channel needs no D1 polling.
|
|
208
|
+
await Promise.all(Object.entries(machine_links(home).sites).filter(([site, link]) => link.private_key && !skip.has(site)).map(async ([site, link]) => {
|
|
209
|
+
let commands;
|
|
210
|
+
try { ({ commands } = await machine_request(site, link, '/commands/claim', { wait }, { timeout: wait * 1000 + COMMAND_REQUEST_MARGIN_MS, signal })); }
|
|
211
|
+
catch (error) { if (!signal?.aborted) { log(`${site}: 领取远程命令失败:${error.message}`); on_failure(); } return; }
|
|
212
|
+
for (const command of commands) {
|
|
213
|
+
// A pairing removed or replaced while the claim waited ends here, as on the live channel.
|
|
214
|
+
const current = machine_links(home).sites[site];
|
|
215
|
+
if (current?.token !== link.token || current?.private_key?.d !== link.private_key?.d) { log(`${site}: 配对已撤销或更换,忽略远程命令 ${command.id}`); continue; }
|
|
216
|
+
const task = answer_command(home, site, link, command, run, log).finally(() => running.delete(task));
|
|
217
|
+
running.add(task);
|
|
218
|
+
}
|
|
219
|
+
}));
|
|
220
|
+
return running;
|
|
221
|
+
}
|