ai_co_discuss 0.3.53 → 0.3.55

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/docs/runner.md CHANGED
@@ -63,7 +63,7 @@ codiscuss local connect https://你的站点
63
63
 
64
64
  手机或平板与电脑配对后,只要这台电脑开机并运行 codiscuss,就能像在电脑上一样操作:建房、邀请与请回成员、翻开卡片设置软件、模型和权限、录入 API 密钥、管理常用项目等。这些操作由网站转交给电脑执行,内容端到端加密,网站只经手密文。
65
65
 
66
- 其他设备能用的目录由电脑决定:只能选择**常用项目**及其中的文件夹。点项目目录栏末尾的文件夹按钮,可以逐层浏览常用项目中的文件夹(只显示文件夹名)。想让其他设备使用更大的范围,就在电脑上把上级目录加为常用项目。以下设置只能在电脑上修改:软件位置、“全放开”权限档、常用项目范围之外的目录。转交的命令只执行一次,发出 10 分钟后作废,所以手机时间需要大致准确。
66
+ 其他设备能用的目录由电脑决定:只能选择**常用项目**及其中的文件夹。点项目目录栏末尾的文件夹按钮,可以逐层浏览常用项目中的文件夹(只显示文件夹名)。想让其他设备使用更大的范围,就在电脑上把上级目录加为常用项目。以下设置只能在电脑上修改:软件位置、常用项目范围之外的目录。转交的命令只执行一次,发出 10 分钟后作废,所以手机时间需要大致准确。
67
67
 
68
68
  **设备配对**:每台手机、平板或其他电脑第一次使用前,都要在这台电脑前配对一次,不需要注册账号或记住密码。
69
69
  1. 在电脑的本机网页“设置 → 账号 → 已配对的设备”中点“添加设备”,显示一个二维码和 12 位配对码,2 分钟有效,只能用一次。
@@ -74,12 +74,10 @@ codiscuss local connect https://你的站点
74
74
  - 设备在浏览器里保存一把不可导出的设备密钥,之后打开网站直接可用。网站只认电脑签过名的设备名单,没有能被盗用的账号密码。
75
75
  - 每台设备授权 30 天,到期前用通行密钥续期。勾选“临时设备”的只授权 1 天,用于借来的电脑。
76
76
  - 新设备只能在电脑前添加,已配对设备不能远程添加。设备丢失时,在其他已配对设备或电脑上撤销它;只有一台外出设备时,回到电脑前撤销并重新配对。
77
- - 设备列表下的“最近记录”列出电脑记下的配对、撤销、远程可写和远程操作(不含内容)。看到不是你做的操作,立即撤销对应设备。
77
+ - 设备列表下的“最近记录”列出电脑记下的配对、撤销、每日确认和远程操作(不含内容)。看到不是你做的操作,立即撤销对应设备。
78
78
  - 清除浏览器数据会删掉设备密钥,需要回到电脑前重新配对。
79
79
 
80
- **远程可写**:房间里有可写(或自动、全放开)成员时,在其他设备上第一次给它发消息或调整它,会先请你用通行密钥确认。确认后,这台设备在这个房间里 12 小时内可以远程操作,可在设备列表中提前撤销。
81
- - 其他设备新建房间时,成员只能是只读;建好后再在房间里开启可写。
82
- - 撤销只阻止之后的远程操作,已经在运行的工作和已改动的文件不会因此撤回。
80
+ **每日确认**:每台设备每天第一次打开网页时,用面容、指纹或设备密码确认一次,之后 24 小时内建房、发消息、设置任意权限档(含可写、全放开)都不再询问。配对和续期本身也算一次确认。撤销设备只阻止之后的远程操作,已经在运行的工作和已改动的文件不会因此撤回。
83
81
 
84
82
  配对后目录“协作房间”直接列出电脑上的房间,与在电脑上看到的一致,可以发消息、上传附件、管理成员、导出备份,并实时更新。对话只保存在电脑上,网站只转交加密内容。
85
83
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai_co_discuss",
3
- "version": "0.3.53",
3
+ "version": "0.3.55",
4
4
  "bin": {
5
5
  "codiscuss": "runner/codiscuss.mjs"
6
6
  },
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Which browsers this computer trusts, decided here alone (see .dev/plans/远程访问安全加固/PROTOCOL.md): pairing by a
3
- * code shown on a trusted screen, a signature on every relayed command, passkeys kept per device, and per-room write
4
- * grants. The website only forwards; nothing it stores can add a device or a grant.
3
+ * code shown on a trusted screen, a signature on every relayed command, passkeys kept per device, and a daily unlock
4
+ * by passkey. The website only forwards; nothing it stores can add a device or unlock one.
5
5
  */
6
6
  import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
7
7
  import { appendFileSync, existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
@@ -24,7 +24,8 @@ export const DEVICE_DAYS = 30;
24
24
  const TEMPORARY_DAYS = 1;
25
25
  // An expired device may still renew itself with its passkey for this long; after that it pairs again.
26
26
  const RENEW_GRACE_DAYS = 30;
27
- export const WRITE_GRANT_MS = 12 * 3600000;
27
+ // A paired device works for this long after its passkey confirms it, then asks again: once a day, not per room.
28
+ export const UNLOCK_MS = 24 * 3600000;
28
29
  const PAIRING_MS = 120000;
29
30
  const PAIRING_ATTEMPTS = 5;
30
31
  const CHALLENGE_MS = 120000;
@@ -36,11 +37,13 @@ const CODE_LENGTH = 12;
36
37
  const PAIR_CONTEXT = 'codiscuss-pair-v1';
37
38
  // What an expired (not yet removed) device may still ask for: only to renew itself.
38
39
  const EXPIRED_ACTIONS = new Set(['passkey-challenge', 'device-renew']);
40
+ // What a locked device may still ask for: only to unlock (or renew) itself with its passkey.
41
+ const LOCKED_ACTIONS = new Set(['passkey-challenge', 'device-unlock', 'device-renew']);
39
42
  // The one action an unpaired browser may send, unsigned: proving it holds a pairing code.
40
43
  export const UNSIGNED_ACTIONS = new Set(['device-pair']);
41
44
  // Device actions answered by this module rather than the launch entrypoint.
42
45
  // New devices are added only at this computer's own screen, so no relayed action starts a pairing.
43
- export const DEVICE_ACTIONS = new Set(['device-pair', 'device-list', 'device-revoke', 'passkey-challenge', 'device-renew', 'write-grant', 'write-revoke']);
46
+ export const DEVICE_ACTIONS = new Set(['device-pair', 'device-list', 'device-revoke', 'passkey-challenge', 'device-renew', 'device-unlock']);
44
47
  // Actions after which the website must receive the new signed device list.
45
48
  const LIST_CHANGES = new Set(['device-pair', 'device-revoke', 'device-renew']);
46
49
 
@@ -58,16 +61,14 @@ function with_lock(home, task) {
58
61
  try {
59
62
  const file = join(home, DEVICES_FILE);
60
63
  const registry = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {};
61
- registry.devices ||= {}; registry.pairings ||= {}; registry.challenges ||= {}; registry.write_grants ||= {};
64
+ registry.devices ||= {}; registry.pairings ||= {}; registry.challenges ||= {};
65
+ // Per-room write grants were replaced by the daily unlock.
66
+ delete registry.write_grants;
62
67
  const now = Date.now();
63
68
  // Drop what has expired so the file stays small and nothing stale can be used.
64
69
  for (const [key, item] of Object.entries(registry.pairings)) if (item.expires_at <= now) delete registry.pairings[key];
65
70
  for (const [key, item] of Object.entries(registry.challenges)) if (item.expires_at <= now) delete registry.challenges[key];
66
71
  for (const [key, device] of Object.entries(registry.devices)) if (device.expires_at + RENEW_GRACE_DAYS * DAY_MS <= now) delete registry.devices[key];
67
- for (const [device, rooms] of Object.entries(registry.write_grants)) {
68
- for (const [room, until] of Object.entries(rooms)) if (until <= now || !registry.devices[device]) delete rooms[room];
69
- if (!Object.keys(rooms).length) delete registry.write_grants[device];
70
- }
71
72
  // Saved even when the task refuses: a used challenge or a counted failure must not come back.
72
73
  try { return task(registry, now); } finally { atomic_json(file, registry); }
73
74
  } finally { rmSync(path, { force: true }); }
@@ -94,7 +95,7 @@ export function recent_audit(home, limit = AUDIT_SHOWN) {
94
95
  function read_registry(home) {
95
96
  const file = join(home, DEVICES_FILE);
96
97
  const registry = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {};
97
- return { devices: registry.devices || {}, write_grants: registry.write_grants || {} };
98
+ return { devices: registry.devices || {} };
98
99
  }
99
100
 
100
101
  /** The uncompressed P-256 point of a JWK, which names the device. */
@@ -152,7 +153,9 @@ export function start_pairing(home, { site, fingerprint = '', temporary = false,
152
153
  function add_device(registry, now, { public_key, label, temporary, by, passkey }) {
153
154
  const id = device_id_of(public_key);
154
155
  registry.devices[id] = { public_key: { kty: 'EC', crv: 'P-256', x: public_key.x, y: public_key.y }, label: clean_label(label), by,
155
- temporary: Boolean(temporary), created_at: now, expires_at: now + (temporary ? TEMPORARY_DAYS : DEVICE_DAYS) * DAY_MS, passkeys: [passkey] };
156
+ temporary: Boolean(temporary), created_at: now, expires_at: now + (temporary ? TEMPORARY_DAYS : DEVICE_DAYS) * DAY_MS, passkeys: [passkey],
157
+ // Pairing itself was confirmed with the passkey, so the device starts unlocked.
158
+ unlocked_until: Math.min(now + UNLOCK_MS, now + (temporary ? TEMPORARY_DAYS : DEVICE_DAYS) * DAY_MS) };
156
159
  return { device_id: id, expires_at: registry.devices[id].expires_at, temporary: Boolean(temporary) };
157
160
  }
158
161
 
@@ -206,20 +209,12 @@ export async function open_signed_request(home, id, sealed, machine_id) {
206
209
  if (device.expires_at <= Date.now() && !EXPIRED_ACTIONS.has(request.action)) {
207
210
  throw Object.assign(new Error(device.temporary ? '临时设备授权已到期,请重新配对' : '设备授权已到期,请用通行密钥续期'), { need: device.temporary ? 'pairing' : 'renew' });
208
211
  }
212
+ if (!(device.unlocked_until > Date.now()) && !LOCKED_ACTIONS.has(request.action)) {
213
+ throw Object.assign(new Error('请用通行密钥确认这台设备(每天一次)'), { need: 'unlock' });
214
+ }
209
215
  return { request, device_id: sealed.device_id };
210
216
  }
211
217
 
212
- /** Whether a device holds a live write grant for a room. */
213
- export function write_allowed(home, device_id, room_id, now = Date.now()) {
214
- const until = read_registry(home).write_grants[device_id]?.[room_id];
215
- return Boolean(until && until > now);
216
- }
217
-
218
- /** The error that asks the page to confirm remote writing with a passkey, then retry. */
219
- export function write_needed(room_id) {
220
- return Object.assign(new Error('这个房间的成员可以改动电脑上的文件;远程操作前需要用通行密钥确认(12 小时有效)'), { need: 'write-grant', room_id });
221
- }
222
-
223
218
  /**
224
219
  * The device list this computer signs for the website: each device's public key and expiry, nothing else. The website
225
220
  * checks requests against it and cannot add a device of its own.
@@ -235,10 +230,10 @@ export async function signed_device_list(home, link, now = Date.now()) {
235
230
 
236
231
  /** List paired devices with their passkeys and write grants, newest first, and the recent audit entries. */
237
232
  export function list_devices(home, current = null) {
238
- const { devices, write_grants } = read_registry(home);
233
+ const { devices } = read_registry(home);
239
234
  return { audit: recent_audit(home), devices: Object.entries(devices).map(([id, device]) => ({ id, label: device.label, temporary: device.temporary, created_at: device.created_at,
240
235
  expires_at: device.expires_at, passkeys: device.passkeys.length, current: id === current,
241
- write_rooms: Object.entries(write_grants[id] || {}).map(([room, until]) => ({ room_id: room, until })) }))
236
+ unlocked_until: device.unlocked_until || 0 }))
242
237
  .sort((left, right) => right.created_at - left.created_at) };
243
238
  }
244
239
 
@@ -251,20 +246,10 @@ export function revoke_device(home, device_id) {
251
246
  return with_lock(home, registry => {
252
247
  const passkeys = (registry.devices[device_id]?.passkeys || []).map(passkey => passkey.id);
253
248
  delete registry.devices[device_id];
254
- delete registry.write_grants[device_id];
255
249
  return { ok: true, passkeys };
256
250
  });
257
251
  }
258
252
 
259
- /** End one device's write grant for one room before it runs out. */
260
- export function revoke_write(home, device_id, room_id) {
261
- audit(home, { event: 'write.revoked', device: device_id, room: room_id });
262
- return with_lock(home, registry => {
263
- if (registry.write_grants[device_id]) delete registry.write_grants[device_id][room_id];
264
- return { ok: true };
265
- });
266
- }
267
-
268
253
  /** The relying party of a site: its exact origin and host name. */
269
254
  function relying_party(site) {
270
255
  const url = new URL(site);
@@ -276,8 +261,7 @@ function relying_party(site) {
276
261
  * other purposes return request options listing only this device's passkeys.
277
262
  */
278
263
  export function passkey_options(home, device_id, site, { purpose, room_id }) {
279
- if (!['write', 'renew'].includes(purpose)) throw new Error('通行密钥用途无效');
280
- if (purpose === 'write' && (typeof room_id !== 'string' || !room_id)) throw new Error('须指定要开启可写的房间');
264
+ if (!['unlock', 'renew'].includes(purpose)) throw new Error('通行密钥用途无效');
281
265
  const { rp_id } = relying_party(site);
282
266
  return with_lock(home, (registry, now) => {
283
267
  const device = registry.devices[device_id];
@@ -321,13 +305,12 @@ async function check_assertion(home, device_id, site, { challenge_id, assertion
321
305
  });
322
306
  }
323
307
 
324
- /** Grant this device remote writing in one room for twelve hours, never past the device's own expiry. */
325
- export async function grant_write(home, device_id, site, { challenge_id, assertion, room_id }) {
326
- return check_assertion(home, device_id, site, { challenge_id, assertion }, 'write', room_id, (device, registry, now) => {
327
- const until = Math.min(now + WRITE_GRANT_MS, device.expires_at);
328
- (registry.write_grants[device_id] ||= {})[room_id] = until;
329
- audit(home, { event: 'write.granted', device: device_id, room: room_id, until });
330
- return { room_id, until };
308
+ /** Unlock this device for a day after its passkey confirms, never past the device's own expiry. */
309
+ export async function unlock_device(home, device_id, site, { challenge_id, assertion }) {
310
+ return check_assertion(home, device_id, site, { challenge_id, assertion }, 'unlock', null, (device, registry, now) => {
311
+ device.unlocked_until = Math.min(now + UNLOCK_MS, device.expires_at);
312
+ audit(home, { event: 'device.unlocked', device: device_id, until: device.unlocked_until });
313
+ return { unlocked_until: device.unlocked_until };
331
314
  });
332
315
  }
333
316
 
@@ -336,6 +319,7 @@ export async function renew_device(home, device_id, site, { challenge_id, assert
336
319
  return check_assertion(home, device_id, site, { challenge_id, assertion }, 'renew', null, device => {
337
320
  if (device.temporary) throw new Error('这台设备不能续期,请重新配对');
338
321
  device.expires_at = Date.now() + DEVICE_DAYS * DAY_MS;
322
+ device.unlocked_until = Date.now() + UNLOCK_MS;
339
323
  audit(home, { event: 'device.renewed', device: device_id });
340
324
  return { expires_at: device.expires_at };
341
325
  });
@@ -350,10 +334,9 @@ export async function device_command(home, { site, machine_id, device_id, on_cha
350
334
  const body = action === 'device-pair' ? pair_device(home, machine_id, site, payload)
351
335
  : action === 'device-list' ? list_devices(home, device_id)
352
336
  : action === 'device-revoke' ? revoke_device(home, String(payload.device_id || ''))
353
- : action === 'write-revoke' ? revoke_write(home, String(payload.device_id || device_id), String(payload.room_id || ''))
354
337
  : action === 'passkey-challenge' ? passkey_options(home, device_id, site, payload)
355
338
  : action === 'device-renew' ? await renew_device(home, device_id, site, payload)
356
- : action === 'write-grant' ? await grant_write(home, device_id, site, payload)
339
+ : action === 'device-unlock' ? await unlock_device(home, device_id, site, payload)
357
340
  : null;
358
341
  if (!body) return { status: 404, body: { error: '未知的设备操作' } };
359
342
  // The website accepts a device only from this computer's signed list, so a change is sent at once.
@@ -12,7 +12,7 @@ import { parseArgs } from 'node:util';
12
12
  import { atomic_json } from './files.mjs';
13
13
  import { read_project_files } from './project_files.mjs';
14
14
  import { guard_remote_request, list_remote_directories } from './remote_policy.mjs';
15
- import { list_devices, machine_fingerprint, revoke_device, revoke_write, start_pairing, write_allowed, write_needed } from './device_auth.mjs';
15
+ import { list_devices, machine_fingerprint, revoke_device, start_pairing } from './device_auth.mjs';
16
16
  import { setup_sidecar } from './adapters/antigravity_app.mjs';
17
17
  import { adapter_of, adoption_adapter, BRANCHABLE_ADAPTERS, branch_session, session_settings, software_defaults, software_effort, software_models } from './adapters/index.mjs';
18
18
  import { ADAPTER_IDS, ADAPTER_PROFILES, adapter_profile, platform_adapters, same_session_family } from './adapters/profiles.mjs';
@@ -100,7 +100,7 @@ stt 按序接收录音片段(upload_id、index、base64 PCM 16 kHz 单声道
100
100
  pick-directory 在本机弹出系统目录选择窗口,返回所选路径;取消时不返回路径。
101
101
  browse-directory 列出常用项目及其中的子文件夹(只含文件夹名,分页),供其他设备选择目录;path 为空时列出常用项目。
102
102
  device-pairing 在本机生成 12 位设备配对码(2 分钟有效,可选 temporary 临时设备、site 指定已绑定的网站),扫码链接带本机密钥指纹;只能在这台电脑上执行,新设备只能在电脑前添加。
103
- devices 列出已配对设备;提供 revoke(设备 id)撤销设备,或 revoke_write(device_id、room_id)撤销远程可写授权。
103
+ devices 列出已配对设备;提供 revoke(设备 id)撤销设备。
104
104
  projects 读取本机已配置的项目(名称与绝对路径);提供 JSON(projects 列表)时整体替换保存。
105
105
  presets 读取本机的专家预设(含未删除的内置预设)与各平台默认描述;提供 JSON(presets 列表、description_templates 和/或 hidden_builtins 已删除的内置预设名)时替换对应部分。
106
106
  machine-link 返回本机连接指定 site 的电脑编号、名称与凭据,供已登录管理员的网页完成配对;不存在时创建。
@@ -1541,42 +1541,6 @@ function room_config_by_id(home, room_id) {
1541
1541
  return paths.length === 1 ? configuration_snapshot(paths[0]).config : null;
1542
1542
  }
1543
1543
 
1544
- /** Whether a member may change files here: its own level, else the room's, or any tool approved for it. */
1545
- const member_writes = (config, profile) => WRITING_ACCESS.has(profile?.project_access || config?.project_access) || profile?.allowed_tools?.length > 0;
1546
-
1547
- /**
1548
- * The room in which a relayed command would put a member with write access to work, judged from what this computer
1549
- * keeps rather than from the request text; null when it would not. Such a command needs the device's write grant.
1550
- */
1551
- export function remote_write_room(home, command, input) {
1552
- if (!input || typeof input !== 'object') return null;
1553
- // Approving a tool widens what a member can do, whatever its level: it always needs the grant.
1554
- if (command === 'room-member-allow') return input.room_id || null;
1555
- if (command === 'room-member') {
1556
- const config = room_config_by_id(home, input.room_id);
1557
- return config && member_writes(config, config.participants?.[input.name]) ? input.room_id : null;
1558
- }
1559
- if (command === 'room-fork') {
1560
- const config = room_config_by_id(home, input.source_room_id);
1561
- return config && Object.values(config.participants || {}).some(profile => member_writes(config, profile)) ? input.source_room_id : null;
1562
- }
1563
- if (command === 'room-workflow') {
1564
- if (input.action === 'host') throw new Error('“允许主持人自建工作流”只能在电脑上开启');
1565
- if (input.action === 'enable') {
1566
- const cards = JSON.stringify(get_workflow(home, input.workflow?.id));
1567
- return /"(project_access|requested_access)":"(write|auto|full)"/.test(cards) ? input.room_id : null;
1568
- }
1569
- if (input.action === 'back') {
1570
- const config = room_config_by_id(home, input.room_id);
1571
- return config && Object.values(config.participants || {}).some(profile => member_writes(config, profile)) ? input.room_id : null;
1572
- }
1573
- }
1574
- if (command === 'start' && input.workflow?.id && /"(project_access|requested_access)":"(write|auto|full)"/.test(JSON.stringify(get_workflow(home, input.workflow.id)))) {
1575
- throw new Error('其他设备新建房间时成员须为只读;建好后在房间里开启可写(需通行密钥确认)');
1576
- }
1577
- return null;
1578
- }
1579
-
1580
1544
  /** Fill program locations another device left out with the ones saved here, so saving defaults cannot drop them. */
1581
1545
  function keep_saved_locations(home, participants) {
1582
1546
  const saved = launch_settings(home).participants;
@@ -1608,14 +1572,11 @@ export async function launch_main(args = process.argv.slice(2), input_text) {
1608
1572
  assert_shared_home(home);
1609
1573
  // Another device's request may only use what this computer granted; the flag is set by the relay, never by the request.
1610
1574
  if (values.remote) {
1611
- const allowed = room => Boolean(values.device) && write_allowed(home, values.device, room);
1612
- guard_remote_request(positionals[0], input, { projects: launch_projects(home).projects, participants: launch_settings(home).participants, home,
1613
- write_allowed: allowed, write_needed });
1614
- const room = remote_write_room(home, positionals[0], input);
1615
- if (room && !allowed(room)) throw write_needed(room);
1575
+ guard_remote_request(positionals[0], input, { projects: launch_projects(home).projects, participants: launch_settings(home).participants, home });
1576
+ if (positionals[0] === 'room-workflow' && input?.action === 'host') throw new Error('“允许主持人自建工作流”只能在电脑上开启');
1616
1577
  // Another device repeats the saved program locations it cannot see; keep them rather than dropping them.
1617
1578
  if (positionals[0] === 'settings' && input?.participants) input.participants = keep_saved_locations(home, input.participants);
1618
- // A room created from another device starts read-only, so its members do not take the tool approvals saved here.
1579
+ // Tool approvals saved here stay this computer's decision; a room created from another device does not take them.
1619
1580
  if (positionals[0] === 'start' && input) input.without_saved_tools = true;
1620
1581
  }
1621
1582
  let result;
@@ -1658,13 +1619,14 @@ export async function launch_main(args = process.argv.slice(2), input_text) {
1658
1619
  result = start_pairing(home, { site, fingerprint: machine_fingerprint(link), temporary: input?.temporary === true });
1659
1620
  }
1660
1621
  else if (positionals[0] === 'devices' && !values.remote) {
1661
- result = input?.revoke ? revoke_device(home, String(input.revoke))
1662
- : input?.revoke_write ? revoke_write(home, String(input.revoke_write.device_id), String(input.revoke_write.room_id)) : list_devices(home);
1622
+ result = input?.revoke ? revoke_device(home, String(input.revoke)) : list_devices(home);
1663
1623
  // The website takes devices only from the list this computer signs, so a revocation is sent at once.
1664
1624
  if (input?.revoke) await (await import('./remote_launch.mjs')).sync_remote_launches(home).catch(() => {});
1665
1625
  }
1666
1626
  else if (positionals[0] === 'machine-link' && input) result = (await import('./remote_launch.mjs')).machine_link(home, input.site);
1667
- else if (positionals[0] === 'start' && input) result = await launch_discussion(home, input, { dry_run: values['dry-run'], browser_request: values.browser });
1627
+ else if (positionals[0] === 'start' && input) {
1628
+ result = await launch_discussion(home, input, { dry_run: values['dry-run'], browser_request: values.browser });
1629
+ }
1668
1630
  else if (positionals[0] === 'room-config' && input) result = await room_configuration(home, input, { local_guard: values['configuration-locked'] });
1669
1631
  else throw new Error(HELP);
1670
1632
  process.stdout.write(`${JSON.stringify(result)}\n`);
@@ -14,7 +14,7 @@ import { acquire_lock } from './discussion_runner.mjs';
14
14
  import { local_settings } from './discussion_setup.mjs';
15
15
  import { machine_links, relay_cipher, save_machine_links, relayed_command, ROOM_ACTIONS, run_launch_command, sync_remote_launches, unseal } from './remote_launch.mjs';
16
16
  import { claim_relayed_command } from './remote_policy.mjs';
17
- import { audit, DEVICE_ACTIONS, device_command, open_signed_request, write_allowed, write_needed } from './device_auth.mjs';
17
+ import { audit, DEVICE_ACTIONS, device_command, open_signed_request } from './device_auth.mjs';
18
18
 
19
19
  const RELAY_PROTOCOL = 'codiscuss-relay';
20
20
  const BRIDGE_TOKEN_BYTES = 32;
@@ -31,9 +31,6 @@ const HEARTBEAT_LATE_MS = 2 * PING_MS + 5000;
31
31
  // A WebSocket frame on the website is at most 1 MiB; leave room for the envelope around the ciphertext.
32
32
  const RESPONSE_MAX_BYTES = 700 * 1024;
33
33
  const ROOM_API_PATH = /^\/api\/(rooms|folders|revisions)(\/[A-Za-z0-9_.~-]+)*(\?[A-Za-z0-9_.~%=&,:+-]*)?$/;
34
- const WRITE_ROOM_PATH = /^\/api\/rooms\/([A-Za-z0-9_.~-]+)\//;
35
- // Members with these levels can change files or run commands on this computer.
36
- const WRITING_ACCESS = new Set(['write', 'auto', 'full', 'own']);
37
34
  // Attachment uploads arrive in parts and are sent to the local website whole, as one browser upload would be.
38
35
  const UPLOAD_PATH = /^\/api\/rooms\/[A-Za-z0-9_-]+\/attachments(\/[A-Za-z0-9_-]+\/import)?(\?[A-Za-z0-9_.~%=&,:+-]*)?$/;
39
36
  const UPLOAD_MAX_BYTES = 20 * 1024 * 1024;
@@ -81,12 +78,6 @@ export async function room_api(home, site, payload, { fetcher = fetch, device }
81
78
  || !Number.isSafeInteger(offset) || offset < 0) {
82
79
  return { status: 400, body: { error: '中转请求地址无效' } };
83
80
  }
84
- // Writing to a room whose members may change files here needs this device's passkey-confirmed grant; stopping does not.
85
- const room = method === 'POST' ? path.match(WRITE_ROOM_PATH)?.[1] : null;
86
- if (device !== undefined && room && !/\/(interrupt|stop)(\?|$)/.test(path) && room_writes(home, room) && !write_allowed(home, device, room)) {
87
- const needed = write_needed(room);
88
- return { status: 403, body: { error: needed.message, need: needed.need, room_id: room } };
89
- }
90
81
  const headers = bridge_headers(home, site);
91
82
  if (typeof token === 'string' && token) headers['X-Discussion-Token'] = token;
92
83
  if (body !== undefined) headers['Content-Type'] = 'application/json';
@@ -100,22 +91,6 @@ export async function room_api(home, site, payload, { fetcher = fetch, device }
100
91
  body_base64: bytes.subarray(offset, offset + RESPONSE_MAX_BYTES).toString('base64') };
101
92
  }
102
93
 
103
- /** Whether a room this computer runs has a member allowed to change files or run commands. */
104
- function room_writes(home, room_id) {
105
- const path = join(home, 'rooms.json');
106
- const configs = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')).configs ?? [] : [];
107
- for (const config_path of configs) {
108
- try {
109
- const config = JSON.parse(readFileSync(config_path, 'utf8'));
110
- // A member without its own level takes the room's, as the runner does when it starts the member.
111
- // Approved tools count too: a shell approved for a read-only member still changes files.
112
- if (config.room === room_id) return Object.values(config.participants || {}).some(profile =>
113
- WRITING_ACCESS.has(profile?.project_access || config.project_access) || profile?.allowed_tools?.length > 0);
114
- } catch { /* A broken configuration starts nothing. */ }
115
- }
116
- return false;
117
- }
118
-
119
94
  /** The local website this computer runs. */
120
95
  function local_origin(home) {
121
96
  return `http://127.0.0.1:${local_settings(home).port || 8765}`;
@@ -77,31 +77,23 @@ function walk(value, visit) {
77
77
 
78
78
  /**
79
79
  * Refuse a relayed request that would reach past what this computer granted: a folder outside the saved projects,
80
- * a program location not already saved here, full access, or write access to a room without this device's
81
- * passkey-confirmed grant (`write_allowed(room_id)`).
80
+ * a program location not already saved here, or 全放开 saved as a default. A device reaches this point only unlocked
81
+ * by its passkey that day, so a room may be given any level.
82
82
  */
83
- export function guard_remote_request(command, input, { projects, participants, home, write_allowed = () => false, write_needed }) {
83
+ export function guard_remote_request(command, input, { projects, participants, home }) {
84
84
  if (LOCAL_ONLY_ACTIONS.has(command)) throw new Error('此操作只能在这台电脑上执行');
85
85
  if (!input || typeof input !== 'object') return;
86
86
  const roots = remote_roots(projects);
87
87
  const saved = Object.values(participants || {});
88
88
  const known = Object.fromEntries(SOFTWARE_FIELDS.map(field => [field, new Set(saved.map(profile => profile[field]).filter(Boolean))]));
89
- let writes = false;
90
89
  walk(input, (key, value) => {
91
90
  if (key === 'cwd' && value) remote_directory(value, { roots, home });
92
91
  else if (SOFTWARE_FIELDS.includes(key) && value && !known[key].has(value)) throw new Error(`其他设备不能更改软件位置(${key}),请在电脑上设置`);
93
- else if (key === 'project_access' && value === 'full') throw new Error('“全放开”只能在电脑上设置');
92
+ // 全放开 may be given to a room's member; it never becomes a saved default from afar.
93
+ else if (key === 'project_access' && value === 'full' && !WRITE_COMMANDS.has(command)) throw new Error('“全放开”不能从其他设备存为默认设置');
94
94
  // `own` comes only from taking over a session on this computer; it may write, so another device cannot claim it.
95
95
  else if (key === 'project_access' && value === 'own') throw new Error('沿用原会话权限(own)只能在电脑上接管会话时产生');
96
- else if (key === 'project_access' && ['write', 'auto'].includes(value)) writes = true;
97
- // An approved tool such as a shell can change files whatever the member's level says.
98
- else if (key === 'allowed_tools' && Array.isArray(value) && value.length) writes = true;
99
96
  });
100
- // Saved presets and workflow cards only describe access; it takes effect in a room, which is checked here.
101
- if (writes && WRITE_COMMANDS.has(command)) {
102
- if (command === 'start' || typeof input.room_id !== 'string') throw new Error('其他设备新建房间时成员须为只读;建好后在房间里开启可写(需通行密钥确认)');
103
- if (!write_allowed(input.room_id)) throw write_needed(input.room_id);
104
- }
105
97
  // A saved project only widens what other devices reach, so a new or moved one must already lie inside the old ones.
106
98
  if (command === 'projects' && Array.isArray(input.projects)) {
107
99
  const existing = new Map((projects || []).map(project => [project.id, project.path]));
package/web/app.js CHANGED
@@ -53,7 +53,7 @@ const RELAY_PING_MS = 25000;
53
53
  const ROOM_RELAY_PATH = /^\/api\/(rooms|folders|revisions)(\/|\?|$)/;
54
54
  // One part of a file sent to the computer stays well inside the relay's 1 MiB frame once encoded and encrypted.
55
55
  const RELAY_UPLOAD_PART_BYTES = 384 * 1024;
56
- const COMPUTER_UNREACHABLE = "电脑未连接:请登录管理员,并确认电脑开机且运行 codiscuss";
56
+ const COMPUTER_UNREACHABLE = "电脑未连接:请先与电脑配对,并确认电脑开机且运行 codiscuss";
57
57
  let local_connection = null;
58
58
  let computer_connecting = false;
59
59
  let relay_machine = null;
@@ -2115,6 +2115,8 @@ function restore_local_connection() {
2115
2115
  async function local_launch_request(action, payload) {
2116
2116
  // A website without rooms of its own reaches the computer only through the relay; pairing itself still needs the local bridge.
2117
2117
  if (relay_machine && (!local_connection || computer_rooms_only() && action !== "machine-link")) return relay_command(action, payload);
2118
+ // Only the computer's own page has a local service at this address; elsewhere the computer is reached through the relay alone.
2119
+ if (!local_connection && !["127.0.0.1", "localhost"].includes(location.hostname)) throw Object.assign(new Error(COMPUTER_UNREACHABLE), { preflight: true });
2118
2120
  const headers = { "Content-Type": "application/json", "X-CoDiscuss-Local": "1" };
2119
2121
  if (local_connection) headers["X-CoDiscuss-Connection"] = local_connection.token;
2120
2122
  let response;
@@ -2248,7 +2250,6 @@ async function device_request(action, payload = {}) {
2248
2250
  if (relay_machine && !state.admin.local) return relay_command(action, payload);
2249
2251
  if (action === "device-list") return local_launch_request("devices", {});
2250
2252
  if (action === "device-revoke") return local_launch_request("devices", { revoke: payload.device_id });
2251
- if (action === "write-revoke") return local_launch_request("devices", { revoke_write: payload });
2252
2253
  throw new Error("请在已配对的设备上操作");
2253
2254
  }
2254
2255
 
@@ -2347,16 +2348,17 @@ async function invite_device(temporary) {
2347
2348
  return local_launch_request("device-pairing", { temporary });
2348
2349
  }
2349
2350
 
2350
- /** Confirm remote writing in one room with this device's passkey; the computer grants twelve hours. */
2351
- async function confirm_write_grant(room_id) {
2352
- const options = await relay_command("passkey-challenge", { purpose: "write", room_id });
2353
- return passkey_prompt({ title: "确认远程可写", description: "这个房间的成员可以改动电脑上的文件或运行命令。确认后,这台设备 12 小时内可以在本房间远程操作;可随时在 设置 → 账号 中撤销。",
2354
- confirm_label: "用通行密钥确认",
2355
- run: async () => {
2356
- const result = await relay_command("write-grant", { challenge_id: options.challenge_id, assertion: await passkey_assertion(options), room_id });
2357
- notify("info", "已开启远程可写,12 小时内有效");
2358
- return result;
2359
- } });
2351
+ // Requests made together while the device is locked share one confirmation.
2352
+ let unlocking = null;
2353
+
2354
+ /** Unlock this device for a day with its passkey; the computer asks once a day, on the first request. */
2355
+ function unlock_this_device() {
2356
+ unlocking ||= (async () => {
2357
+ const options = await relay_command("passkey-challenge", { purpose: "unlock" });
2358
+ return passkey_prompt({ title: "确认是你", description: "用面容、指纹或设备密码确认后,这台设备 24 小时内可以操作电脑上的房间。", confirm_label: "确认",
2359
+ run: async () => relay_command("device-unlock", { challenge_id: options.challenge_id, assertion: await passkey_assertion(options) }) });
2360
+ })().finally(() => { unlocking = null; });
2361
+ return unlocking;
2360
2362
  }
2361
2363
 
2362
2364
  /** Extend this device's trust by thirty days with its passkey. */
@@ -2371,12 +2373,12 @@ async function renew_this_device() {
2371
2373
  }
2372
2374
 
2373
2375
  /**
2374
- * React to what the computer asked for: pair or renew this device (shown in settings), or confirm writing in a room
2375
- * and run the request once more. Returns true when the request should be retried.
2376
+ * React to what the computer asked for: pair or renew this device (shown in settings), or unlock it for the day and
2377
+ * run the request once more. Returns true when the request should be retried.
2376
2378
  */
2377
2379
  async function meet_device_need(error) {
2378
- if (error.need === "write-grant" && error.room_id) {
2379
- await confirm_write_grant(error.room_id);
2380
+ if (error.need === "unlock") {
2381
+ await unlock_this_device();
2380
2382
  return true;
2381
2383
  }
2382
2384
  if (error.need === "pairing" && state.admin.devices) forget_pairing();
@@ -2781,7 +2783,6 @@ async function load_launch_settings() {
2781
2783
  state.launch.connect_command = local ? "codiscuss local open" : `codiscuss local connect "${location.origin}"`;
2782
2784
  try {
2783
2785
  local_connection = null;
2784
- relay_machine = null;
2785
2786
  take_pairing_code();
2786
2787
  local_connection = restore_local_connection();
2787
2788
  state.launch.remote = Boolean(local_connection);
@@ -2790,8 +2791,9 @@ async function load_launch_settings() {
2790
2791
  // Pair first, so the computer this page runs on can then be reached through the relay like any other device.
2791
2792
  state.launch.relay = await load_relay_machine();
2792
2793
  // An online computer that publishes a command key does everything the local service would; an older one only creates rooms.
2793
- if (!state.launch.relay?.online || !state.launch.relay.public_key) return;
2794
- relay_machine = state.launch.relay;
2794
+ // The previous one stays in use until this answer arrives, so requests made meanwhile still reach the computer.
2795
+ relay_machine = state.launch.relay?.online && state.launch.relay.public_key ? state.launch.relay : null;
2796
+ if (!relay_machine) return;
2795
2797
  state.launch.remote = true;
2796
2798
  }
2797
2799
  const settings = await local_launch_request("settings");
@@ -4700,7 +4702,7 @@ window.discuss_view = { snapshot: view_snapshot, write_field: write_view_field,
4700
4702
  admin: { register: register_admin, login: login_admin, logout: logout_admin,
4701
4703
  update_profile: update_admin_profile, avatar_from_file: admin_avatar_from_file, validate: validate_admin_form,
4702
4704
  sessions: admin_sessions, revoke: revoke_admin_sessions, change_password: change_admin_password },
4703
- device: { request: device_request, pair: pair_this_device, take_scanned: take_scanned_pairing, invite: invite_device, renew: renew_this_device, confirm_write: confirm_write_grant, forget: forget_pairing,
4705
+ device: { request: device_request, pair: pair_this_device, take_scanned: take_scanned_pairing, invite: invite_device, renew: renew_this_device, forget: forget_pairing,
4704
4706
  label: device_label, prompt_confirm: () => state.device.prompt?.run(), prompt_cancel: () => state.device.prompt?.cancel() },
4705
4707
  request: request_json, rewind_plan, identities: IDENTITIES, display_name, mention_handles, mentioned_members, render_room_markdown, sources: SOURCE_LABELS,
4706
4708
  set_member_access, set_member_model, allow_denials, set_member_adapter, reload_room_models, read_room_files, limits_label, stop_reason_label, participant_model, participant_files, participant_session_hint, participant_access,