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.
Files changed (43) hide show
  1. package/.agents/skills/discuss-host/SKILL.md +1 -0
  2. package/.agents/skills/discuss-host/references/discussion.md +113 -113
  3. package/.agents/skills/discuss-host/references/free.md +36 -34
  4. package/.agents/skills/discuss-host/references/goal.md +35 -0
  5. package/.agents/skills/discuss-participant/SKILL.md +1 -0
  6. package/.agents/skills/workflow-creator/SKILL.md +2 -2
  7. package/README.md +3 -1
  8. package/backend/admin.py +104 -35
  9. package/backend/free_room.py +2170 -2136
  10. package/backend/goal_room.py +358 -0
  11. package/backend/mode_room.py +105 -0
  12. package/backend/schema.sql +1593 -1510
  13. package/backend/server.py +2636 -2555
  14. package/backend/session_room.py +1180 -1148
  15. package/docs/THIRD_PARTY_NOTICES.md +1293 -1264
  16. package/docs/deployment.md +5 -1
  17. package/docs/en/workflow.md +15 -1
  18. package/docs/runner.md +28 -7
  19. package/docs/workflow.md +15 -1
  20. package/package.json +3 -2
  21. package/runner/adapters/api.mjs +333 -313
  22. package/runner/adapters/claude.mjs +12 -16
  23. package/runner/admin_account.mjs +12 -19
  24. package/runner/device_auth.mjs +369 -0
  25. package/runner/discuss.py +1208 -1125
  26. package/runner/discussion_launch.mjs +107 -12
  27. package/runner/discussion_service.mjs +25 -1
  28. package/runner/expert_prompts.mjs +132 -132
  29. package/runner/free_runner.mjs +982 -910
  30. package/runner/pinyin_table.mjs +406 -0
  31. package/runner/project_files.mjs +52 -3
  32. package/runner/relay_channel.mjs +51 -7
  33. package/runner/remote_launch.mjs +221 -235
  34. package/runner/remote_policy.mjs +156 -0
  35. package/runner/scheduler.mjs +459 -429
  36. package/runner/stt.mjs +2 -2
  37. package/runner/tts.mjs +74 -113
  38. package/runner/tts_providers.mjs +205 -0
  39. package/runner/webauthn.mjs +155 -0
  40. package/runner/workflows.mjs +371 -348
  41. package/web/app.js +355 -51
  42. package/web/modern.css +1 -1
  43. package/web/modern.js +5764 -3816
@@ -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 { launch_discussion, launch_projects, launch_settings, platform_of } from './discussion_launch.mjs';
16
-
17
- const MACHINE_TOKEN_BYTES = 32;
18
- const MACHINE_NAME_MAX = 60;
19
- const SYNC_TIMEOUT_MS = 10000;
20
- const LINKS_FILE = 'machine-links.json';
21
- // The website holds a claim this long when nothing is queued, so a waiting device is answered at once.
22
- export const COMMAND_WAIT_SECONDS = 20;
23
- const COMMAND_REQUEST_MARGIN_MS = 10000;
24
- // Matches LOCAL_LAUNCH_TIMEOUT in backend/server.py, which runs the same entrypoint for the local website.
25
- const COMMAND_TIMEOUT_MS = 60000;
26
- const RELAY_CIPHER_INFO = 'codiscuss-relay-v1';
27
- const LAUNCH_SCRIPT = fileURLToPath(new URL('./discussion_launch.mjs', import.meta.url));
28
- // What a connected browser may ask the local bridge for, minus the folder dialog nobody would see and pairing itself.
29
- const RELAYED_ACTIONS = new Set(['settings', 'launch', 'room-models', 'room-member', 'room-member-access', 'room-member-allow', 'room-member-adapter',
30
- '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', 'admin-account']);
31
- export const ROOM_ACTIONS = new Set(['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', 'room-workflow', 'workflows']);
33
-
34
- /** Read every site this computer is paired with. */
35
- export function machine_links(home) {
36
- const path = join(home, LINKS_FILE);
37
- return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : { sites: {} };
38
- }
39
-
40
- /** A P-256 key pair for relayed commands; the private half never leaves this computer. */
41
- function command_keys() {
42
- const { publicKey, privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
43
- const { kty, crv, x, y } = publicKey.export({ format: 'jwk' });
44
- return { public_key: { kty, crv, x, y }, private_key: privateKey.export({ format: 'jwk' }) };
45
- }
46
-
47
- /** Give every paired site a command key pair, kept beside its token; older pairings gain one on first use. */
48
- function keyed_links(home) {
49
- const links = machine_links(home);
50
- const missing = Object.values(links.sites).filter(link => !link.private_key);
51
- for (const link of missing) Object.assign(link, command_keys());
52
- if (missing.length) atomic_json(join(home, LINKS_FILE), links);
53
- return links;
54
- }
55
-
56
- /** Return this computer's identity for one site, creating it on first use; the browser then pairs it as administrator. */
57
- export function machine_link(home, site) {
58
- const origin = new URL(site).origin;
59
- const links = machine_links(home);
60
- links.sites[origin] ||= { id: randomUUID().replaceAll('-', ''), token: randomBytes(MACHINE_TOKEN_BYTES).toString('hex'),
61
- name: (hostname() || '本机').slice(0, MACHINE_NAME_MAX), ...command_keys() };
62
- mkdirSync(home, { recursive: true, mode: 0o700 });
63
- atomic_json(join(home, LINKS_FILE), links);
64
- // The browser forwards this to the website, so the private key stays out of it.
65
- const { id, token, name } = links.sites[origin];
66
- return { id, token, name };
67
- }
68
-
69
- /** Forget a site's pairing so it can no longer hand launches to this computer. */
70
- export function unlink_machine(home, site) {
71
- const links = machine_links(home);
72
- if (!links.sites[site]) return;
73
- delete links.sites[site];
74
- atomic_json(join(home, LINKS_FILE), links);
75
- }
76
-
77
- /** Resolve the API key saved for a profile's base URL, as the connected browser does. */
78
- function api_key_ref(profile, keys) {
79
- if (profile.api_key_ref || !profile.base_url) return profile.api_key_ref;
80
- try { return keys[new URL(profile.base_url).href.replace(/\/+$/, '')]; } catch { return undefined; }
81
- }
82
-
83
- /** Platforms this computer can wake without further setup, and the client each uses. */
84
- export function ready_clients(home) {
85
- const keys = saved_api_keys(home);
86
- return Object.fromEntries(Object.entries(launch_settings(home).participants).filter(([, profile]) => {
87
- const facts = adapter_profile(profile.adapter);
88
- if (!facts) return false;
89
- // A model API needs its endpoint and key; other software needs its installed location.
90
- return facts.kind === 'api' ? Boolean(profile.base_url && profile.api_protocol && profile.model && api_key_ref(profile, keys))
91
- : Boolean(profile[facts.location]);
92
- }).map(([name, profile]) => [name, profile.adapter]));
93
- }
94
-
95
- /** Turn a relayed request into the request the connected browser would send, using this computer's directory and keys. */
96
- export function local_request(home, site, request) {
97
- const { local_project, ...input } = request;
98
- if (input.cwd || input.access_file) throw new Error('远程启动只能选择电脑上已配置的项目');
99
- const project = local_project ? launch_projects(home).projects.find(item => item.id === local_project) : null;
100
- if (local_project && !project) throw new Error('所选项目已从这台电脑移除,请重新选择');
101
- const keys = saved_api_keys(home), defaults = launch_settings(home).participants;
102
- const participants = { ...(input.participants || {}) };
103
- for (const member of input.room?.members || []) {
104
- const profile = defaults[platform_of(member.name)];
105
- const ref = adapter_profile(profile?.adapter)?.kind === 'api' && !participants[member.name]?.api_key_ref ? api_key_ref(profile, keys) : undefined;
106
- if (ref) participants[member.name] = { ...participants[member.name], api_key_ref: ref };
107
- }
108
- return { ...input, participants, site, entry: 'web', ...(project ? { cwd: project.path } : {}) };
109
- }
110
-
111
- async function machine_request(site, link, path, body, { timeout = SYNC_TIMEOUT_MS, signal } = {}) {
112
- const response = await fetch(`${site}/api/machines/${link.id}${path}`, { method: 'POST', redirect: 'error',
113
- signal: AbortSignal.any([AbortSignal.timeout(timeout), ...(signal ? [signal] : [])]),
114
- headers: { 'Content-Type': 'application/json', 'X-Machine-Token': link.token }, body: JSON.stringify(body) });
115
- const result = await response.json();
116
- if (!response.ok) throw new Error(`HTTP ${response.status}: ${result.error || '请求失败'}`);
117
- return result;
118
- }
119
-
120
- /** Publish projects and clients to each paired site, then run every launch it relays, one at a time. */
121
- export async function sync_remote_launches(home, { launch = launch_discussion, log = () => {} } = {}) {
122
- const projects = launch_projects(home).projects.filter(project => project.id).map(({ id, name }) => ({ id, name }));
123
- const clients = ready_clients(home);
124
- for (const [site, link] of Object.entries(keyed_links(home).sites)) {
125
- let launches;
126
- try { ({ launches } = await machine_request(site, link, '/sync', { projects, clients, public_key: link.public_key })); }
127
- catch (error) { log(`${site}: 同步电脑状态失败:${error.message}`); continue; }
128
- for (const { id, request } of launches) {
129
- const action = body => machine_request(site, link, `/launches/${encodeURIComponent(id)}`, body);
130
- try {
131
- await action({ action: 'claim' });
132
- const result = await launch(home, local_request(home, site, request), { browser_request: true });
133
- await action({ action: 'finish', room_id: result.room.id, status: result.status });
134
- log(`${site}: 远程启动 ${id} 已创建房间 ${result.room.id}`);
135
- } catch (error) {
136
- log(`${site}: 远程启动 ${id} 失败:${error.message}`);
137
- await action({ action: 'fail', error: error.message, stage: error.launch?.stage || 'not_started',
138
- ...(error.launch?.room_id ? { room_id: error.launch.room_id } : {}) }).catch(() => {});
139
- }
140
- }
141
- }
142
- }
143
-
144
- /** The AES-GCM key shared with one sending device, derived from its ephemeral key and this computer's private key. */
145
- export async function relay_cipher(private_jwk, public_jwk) {
146
- const curve = { name: 'ECDH', namedCurve: 'P-256' };
147
- const own = await crypto.subtle.importKey('jwk', private_jwk, curve, false, ['deriveBits']);
148
- const peer = await crypto.subtle.importKey('jwk', public_jwk, curve, false, []);
149
- const material = await crypto.subtle.importKey('raw', await crypto.subtle.deriveBits({ name: 'ECDH', public: peer }, own, 256), 'HKDF', false, ['deriveKey']);
150
- return crypto.subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(), info: new TextEncoder().encode(RELAY_CIPHER_INFO) },
151
- material, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
152
- }
153
-
154
- /** Encrypt a value bound to its command, so the site cannot swap one command's content into another. */
155
- export async function seal(key, value, label) {
156
- const iv = crypto.getRandomValues(new Uint8Array(12));
157
- const data = await crypto.subtle.encrypt({ name: 'AES-GCM', iv, additionalData: new TextEncoder().encode(label) }, key, new TextEncoder().encode(JSON.stringify(value)));
158
- return { iv: Buffer.from(iv).toString('base64'), data: Buffer.from(data).toString('base64') };
159
- }
160
-
161
- /** Decrypt a value sealed for the same command label. */
162
- export async function unseal(key, envelope, label) {
163
- const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: Buffer.from(envelope.iv, 'base64'), additionalData: new TextEncoder().encode(label) },
164
- key, Buffer.from(envelope.data, 'base64'));
165
- return JSON.parse(new TextDecoder().decode(plain));
166
- }
167
-
168
- /** Turn a relayed action into the local entrypoint call the local website makes, pinned to the relaying site. */
169
- export function relayed_command(site, action, payload) {
170
- if (!RELAYED_ACTIONS.has(action)) throw new Error('该操作不能经其他设备执行');
171
- if (payload === undefined ? action !== 'settings' : !payload || typeof payload !== 'object' || Array.isArray(payload)) throw new Error('命令内容无效');
172
- let input = payload;
173
- if (ROOM_ACTIONS.has(action)) {
174
- const { access_file, ...rest } = payload;
175
- input = { ...rest, site, entry: 'web' };
176
- }
177
- // Choosing a folder belongs to the computer's own screen; another device imports by pasting.
178
- if (action === 'workflows' && payload.folder) throw new Error('其他设备请用粘贴导入工作流');
179
- if (action === 'admin-account') input = { site, username: payload.username, password: payload.password };
180
- return { command: action === 'launch' ? 'start' : action, input, browser: action === 'launch' };
181
- }
182
-
183
- /** Run the local entrypoint as the local website does and answer in its HTTP shape. */
184
- export function run_launch_command({ command, input, browser }) {
185
- return new Promise(resolve => {
186
- const args = [LAUNCH_SCRIPT, command, ...(browser ? ['--browser'] : []), ...(input === undefined ? [] : ['--stdin'])];
187
- const child = spawn(process.execPath, args, { windowsHide: true });
188
- let stdout = '', stderr = '', timed_out = false;
189
- child.stdout.setEncoding('utf8').on('data', chunk => { stdout += chunk; });
190
- child.stderr.setEncoding('utf8').on('data', chunk => { stderr += chunk; });
191
- const timer = setTimeout(() => { timed_out = true; child.kill(); }, COMMAND_TIMEOUT_MS);
192
- child.on('error', error => { clearTimeout(timer); resolve({ status: 400, body: { error: error.message } }); });
193
- child.on('close', code => {
194
- clearTimeout(timer);
195
- if (timed_out) return resolve({ status: 400, body: { error: '启动结果未确认,请核对房间状态后沿用原配置重试' } });
196
- try { resolve(code ? { status: 400, body: JSON.parse(stderr.trim()) } : { status: 200, body: JSON.parse(stdout) }); }
197
- catch { resolve({ status: 400, body: { error: stderr.trim() || '本机操作失败' } }); }
198
- });
199
- child.stdin.end(input === undefined ? '' : JSON.stringify(input));
200
- });
201
- }
202
-
203
- /** Decrypt one claimed command, run it, and return the encrypted answer to the device waiting on it. */
204
- async function answer_command(site, link, { id, envelope }, run, log) {
205
- let key, request;
206
- try {
207
- key = await relay_cipher(link.private_key, envelope.key);
208
- request = await unseal(key, envelope, id);
209
- } catch { return log(`${site}: 远程命令 ${id} 无法解密,已忽略`); }
210
- let response;
211
- try { response = await run(relayed_command(site, request.action, request.payload)); }
212
- catch (error) { response = { status: 400, body: { error: error.message } }; }
213
- try { await machine_request(site, link, `/commands/${encodeURIComponent(id)}`, { envelope: await seal(key, response, `${id}:response`) }); }
214
- catch (error) { log(`${site}: 远程命令 ${id} 回传失败:${error.message}`); }
215
- }
216
-
217
- /**
218
- * Wait on every paired site for relayed commands and start each one; resolves once the claims return, with the commands
219
- * still running in `running` so a slow model list never holds up the next claim. `on_failure` hears a claim that failed,
220
- * so the caller can wait before retrying while a successful claim is followed at once.
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
+ }