diff --git a/packages/live-host/README.md b/packages/live-host/README.md index 6953e7ad97f..f13aeaf650e 100644 --- a/packages/live-host/README.md +++ b/packages/live-host/README.md @@ -122,6 +122,9 @@ Capture Mode(On Demand/Live Feed)三个同级设置组,以及独立 daem `~/.qwen-live/config.json`,也支持 daemon 的 `QWEN_LIVE_DATA_DIR`)。保存后需重启 Qwen Live 才应用手动修改。旧 daemon 或内置 `qwen serve` 不提供此入口能力;文件 缺失、不是常规文件(包括符号链接)或编辑器打开失败时会提示,不自动创建或覆盖配置。 +运行时保存(语言、显示器与 Memory 偏好)通过独占临时文件加 rename 原子重写 +`config.json`,并在 macOS/Linux 上置为仅所有者可访问(`0600`);Windows 没有 +POSIX 权限位,文件隔离性取决于数据目录自身的 ACL。 设置标题栏可以拖动,与小球共享位置记忆;打开时先等待原生窗口完成屏内定位再显示, 避免边缘处先露出被裁切的面板。用户说话的小音量视觉响应已增强,保留有界动画和缓慢 回落,不会提高发送给模型的音频音量。 @@ -267,7 +270,13 @@ daemon 的 debug 模式另外为视觉 Monitor 保存真实请求,目录为系 `proactive.monitor_debug_started` 和 `proactive.monitor_request_saved` 日志给出绝对路径。 仅 daemon debug 开启;Host 的 `--live-debug` 单独启用不会录制,纯音频 Monitor 也不录制。 启动及新建 Monitor 时清理,只保留最近创建的 10 个 Monitor(不是最近 10 次请求)。 -被清理的 Monitor 继续运行但停止录制;文件仅当前用户可访问。内容包含真实屏幕/摄像头、 +删除尽力而为:无法移除的归档(例如 Windows 上被其他进程占用文件)会超出该上限 +保留,上报 `proactive.monitor_debug_prune_failed` 并附带失败原因,之后的清理 +会重试直至可以移除;`EACCES` 等来自删除本身的持续性原因需要手动清理。 +未通过归属校验的归档会被静默跳过、不予上报,也不计入该上限,需人工查找并清理。 +被清理的 Monitor 继续运行但停止录制。文件仅在 macOS/Linux 上保证仅当前用户可访问 +(0700/0600 强制执行,不满足则拒绝);Windows 无法校验 POSIX 权限位, +隔离性仅取决于系统临时目录自身继承的 ACL。内容包含真实屏幕/摄像头、 任务文本和混合 Monitor 的麦克风输入,虽然不保存连接凭据,画面或音频中的秘密不会被脱敏。 录制失败会单独报错而不影响通话;长时间 debug 可能占用较多磁盘,诊断完请关闭 debug。 完整格式与清理规则见 [Qwen Live README](../qwen-live/README.md)。 diff --git a/packages/qwen-live/README.md b/packages/qwen-live/README.md index 787fdb4a307..340b7e8e409 100644 --- a/packages/qwen-live/README.md +++ b/packages/qwen-live/README.md @@ -224,6 +224,10 @@ capability-negotiated; older Hosts/daemons keep their existing behavior. Configuration comes from `~/.qwen-live/config.json` (generated by `init`), with environment variables (`DASHSCOPE_API_KEY`, `QWEN_LIVE_*`) as overrides. +Runtime saves (language, display and memory preferences) rewrite `config.json` +atomically through an exclusive temporary file plus rename and mark it +owner-only (`0600`) on macOS/Linux; Windows has no POSIX permission bits, so +there the file's isolation depends on the data directory's own ACLs. ```jsonc { @@ -401,11 +405,22 @@ history. Queued/dropped frames are not presented as sent frames. These are **sensitive recordings of real screen/camera content, task prompts and, for audio/visual Monitors, microphone audio**. Connection credentials are omitted; visible or spoken secrets inside media are not redacted. Directories/files are -owner-only. Debug startup and new Monitor creation keep only the ten most -recently created Monitor directories; this is not a ten-request or disk-size -limit. An evicted Monitor keeps running but stops recording and logs skipped -requests. Disk/permission failures or exceeding the 32 MiB pending-write budget -disable that recorder and log an incomplete recording without stopping the call. +**owner-only on macOS and Linux** (enforced 0700/0600 and rejected otherwise); +on Windows the store cannot check POSIX modes, so isolation is only whatever +ACLs the OS temporary directory inherits. Debug startup and new Monitor +creation keep only the ten most recently created Monitor directories; this is +not a ten-request or disk-size limit. Deletion is best-effort: an archive +that cannot be removed (for example a file held open by another process on +Windows) is kept past this cap and reported as +`proactive.monitor_debug_prune_failed` with the failure reason, and later +prunes retry it until it can be removed; a persistent reason such as `EACCES` +from the deletion itself needs manual cleanup. An archive that fails the +ownership scan is skipped silently rather than reported, is not counted +against this cap, and must be found and removed by hand. An evicted Monitor +keeps running +but stops recording and logs skipped requests. Disk/permission failures or +exceeding the 32 MiB pending-write budget disable that recorder and log an +incomplete recording without stopping the call. Long-running debug Monitors can consume significant disk space; disable debug after diagnosis and do not share recordings without reviewing their contents. diff --git a/packages/qwen-live/src/language-preferences.test.ts b/packages/qwen-live/src/language-preferences.test.ts index 5609af9a524..f9bdb626e84 100644 --- a/packages/qwen-live/src/language-preferences.test.ts +++ b/packages/qwen-live/src/language-preferences.test.ts @@ -64,11 +64,22 @@ describe('Live language preference', () => { ...raw, language: 'zh-CN', }); - if (process.platform !== 'win32') - expect(statSync(path).mode & 0o777).toBe(0o600); expect(readdirSync(dataDir)).toEqual(['config.json']); }); + // Windows has no POSIX permission bits, so skip (reportedly) rather than + // passing a test that asserted nothing. + it.skipIf(process.platform === 'win32')( + 'writes the language config with 0600 permissions', + () => { + const dataDir = directory(); + const path = join(dataDir, 'config.json'); + writeFileSync(path, '{"realtimeApiKey":"fixture-key"}'); + persistLanguagePreference(dataDir, 'zh-CN'); + expect(statSync(path).mode & 0o777).toBe(0o600); + }, + ); + it('rejects invalid language before writing and preserves config on atomic rename failure', () => { const dataDir = directory(); const path = join(dataDir, 'config.json'); diff --git a/packages/qwen-live/src/memory/service.test.ts b/packages/qwen-live/src/memory/service.test.ts index 039ffa215bf..d0426437577 100644 --- a/packages/qwen-live/src/memory/service.test.ts +++ b/packages/qwen-live/src/memory/service.test.ts @@ -129,9 +129,6 @@ describe('persistMemoryPreferences', () => { retrieve: { useVector: false }, }); expect(resolved.updater.model).toBe('custom-memory-model'); - if (process.platform !== 'win32') { - expect(statSync(configPath).mode & 0o777).toBe(0o600); - } expect(readdirSync(dataDir)).toEqual(['config.json']); }); @@ -177,12 +174,24 @@ describe('persistMemoryPreferences', () => { }); expect(saved.memory.observer).not.toHaveProperty('model'); expect(resolved.observer.model).toBe('custom-memory-model'); - if (process.platform !== 'win32') { - expect(statSync(configPath).mode & 0o777).toBe(0o600); - } expect(readdirSync(dataDir)).toEqual(['config.json']); }); + // Windows has no POSIX permission bits, so skip (reportedly) rather than + // passing a test that asserted nothing. + it.skipIf(process.platform === 'win32')( + 'writes the memory config with 0600 permissions', + () => { + const plain = fixture(); + persistMemoryPreferences(plain.dataDir, { enabled: false }); + expect(statSync(plain.configPath).mode & 0o777).toBe(0o600); + const bom = fixture(); + writeFileSync(bom.configPath, `\uFEFF${JSON.stringify(bom.raw)}`); + persistMemoryPreferences(bom.dataDir, { enabled: false }); + expect(statSync(bom.configPath).mode & 0o777).toBe(0o600); + }, + ); + it('leaves the original file untouched and cleans its temporary file when replacement fails', () => { const { dataDir, configPath } = fixture(); const before = readFileSync(configPath, 'utf8'); diff --git a/packages/qwen-live/src/proactive/monitor-debug-store.test.ts b/packages/qwen-live/src/proactive/monitor-debug-store.test.ts index 32b5e5a0e1b..2270f33c223 100644 --- a/packages/qwen-live/src/proactive/monitor-debug-store.test.ts +++ b/packages/qwen-live/src/proactive/monitor-debug-store.test.ts @@ -26,6 +26,58 @@ import { type MonitorDebugRecorder, } from './monitor-debug-store.js'; +const rmFailures = vi.hoisted(() => new Map()); +const rmCalls = vi.hoisted( + () => [] as Array<{ path: string; options: unknown }>, +); +// One-shot lstat tampering after `after` real calls, simulating a concurrent +// pruner or tamperer acting between the prune scan and the deletion phase. +const lstatTampers = vi.hoisted( + () => new Map(), +); + +vi.mock('node:fs/promises', async (original) => { + const fs = await original(); + return { + ...fs, + lstat: async ( + path: Parameters[0], + options?: Parameters[1], + ) => { + const key = String(path); + const tamper = lstatTampers.get(key); + if (tamper) { + if (tamper.after > 0) { + tamper.after -= 1; + } else { + lstatTampers.delete(key); + if (tamper.effect === 'symlink') { + const stat = await fs.lstat(path); + stat.isSymbolicLink = () => true; + return stat; + } + throw Object.assign(new Error('no such file or directory'), { + code: 'ENOENT', + }); + } + } + return fs.lstat(path, options); + }, + rm: async ( + path: Parameters[0], + options?: Parameters[1], + ) => { + rmCalls.push({ path: String(path), options }); + const remaining = rmFailures.get(String(path)); + if (remaining) { + rmFailures.set(String(path), remaining - 1); + throw Object.assign(new Error('busy'), { code: 'EBUSY' }); + } + return fs.rm(path, options); + }, + }; +}); + const INFO: MonitorDebugInfo = { taskId: 'monitor-1', taskGeneration: 3, @@ -78,6 +130,9 @@ describe('MonitorDebugStore', () => { afterEach(async () => { await Promise.all(stores.map((item) => item.flush())); vi.restoreAllMocks(); + rmFailures.clear(); + rmCalls.length = 0; + lstatTampers.clear(); await rm(temporary, { recursive: true, force: true }); }); @@ -225,7 +280,31 @@ describe('MonitorDebugStore', () => { text: 'Reply [redacted]', result: 'reply', }); - if (process.platform !== 'win32') { + expect(log).toHaveBeenCalledWith( + 'proactive.monitor_request_saved', + expect.objectContaining({ + directory: archive.directory, + requestDirectory: directory, + imageFrames: 2, + audioBytes: audio.length + silence.length, + }), + ); + }); + + // Windows has no POSIX permission bits, so skip (reportedly) rather than + // passing a test that asserted nothing. + it.skipIf(process.platform === 'win32')( + 'archives monitor recordings with private permissions', + async () => { + const archive = await recorder(); + sendImage(archive, Buffer.from([0xff, 0xd8, 1, 2, 0xff, 0xd9])); + sendImage(archive, Buffer.from([0xff, 0xd8, 3, 4, 0xff, 0xd9])); + sendAudio(archive, Buffer.from([0, 0, 0xff, 0x7f, 0, 0x80])); + commit(archive); + archive.result({ status: 'completed', text: 'reply' }); + await store.flush(); + + const directory = join(archive.directory, 'requests', '000001'); for (const path of [ root, archive.directory, @@ -246,17 +325,8 @@ describe('MonitorDebugStore', () => { ]) { expect((await lstat(path)).mode & 0o777).toBe(0o600); } - } - expect(log).toHaveBeenCalledWith( - 'proactive.monitor_request_saved', - expect.objectContaining({ - directory: archive.directory, - requestDirectory: directory, - imageFrames: 2, - audioBytes: audio.length + silence.length, - }), - ); - }); + }, + ); it('separates requests and transports without copying old media or cleared inputs', async () => { const archive = await recorder(); @@ -406,9 +476,106 @@ describe('MonitorDebugStore', () => { }); }); + it('keeps pruning and recording when one stale archive cannot be deleted', async () => { + await mkdir(root, { mode: 0o700 }); + const owned: string[] = []; + for (let time = 1; time <= 12; time += 1) + owned.push(await ownedDirectory(time)); + // The prune visits stale archives newest-first, so blocking owned[1] + // leaves the older owned[0] to prove the loop continued. Block the media + // subtree, as a held-open media file does: the removal must fail before + // the marker is touched, so the next prune still recognizes the archive. + rmFailures.set(join(owned[1]!, 'requests'), 1); + expect(await store.initialize()).toBe(true); + await expect(lstat(owned[0]!)).rejects.toMatchObject({ code: 'ENOENT' }); + expect((await lstat(owned[1]!)).isDirectory()).toBe(true); + expect((await lstat(join(owned[1]!, 'monitor.json'))).isFile()).toBe(true); + expect(log).toHaveBeenCalledWith( + 'proactive.monitor_debug_prune_failed', + expect.objectContaining({ + directory: owned[1], + retained: true, + reason: 'EBUSY', + }), + ); + // A retained archive must never be logged as destroyed. + expect(log).not.toHaveBeenCalledWith( + 'proactive.monitor_debug_pruned', + expect.objectContaining({ directory: owned[1] }), + ); + // Once the handle clears, the next prune retries and removes the archive. + expect(await store.initialize()).toBe(true); + await expect(lstat(owned[1]!)).rejects.toMatchObject({ code: 'ENOENT' }); + expect(log).toHaveBeenCalledWith('proactive.monitor_debug_pruned', { + directory: owned[1], + }); + // maxRetries rides out a transient handle (AV scanner/indexer) on Windows; + // the budget must ride on both removals, not just the media subtree. + for (const path of [join(owned[1]!, 'requests'), owned[1]!]) + expect(rmCalls.find((call) => call.path === path)?.options).toEqual( + expect.objectContaining({ + recursive: true, + force: true, + maxRetries: 3, + }), + ); + const recorder = store.create(INFO); + expect(recorder).toBeDefined(); + await recorder!.start(); + expect(log).not.toHaveBeenCalledWith( + 'proactive.monitor_debug_failed', + expect.objectContaining({ reason: 'initialization_failed' }), + ); + }); + + it('reports nothing when a concurrent pruner removes a stale archive first', async () => { + await mkdir(root, { mode: 0o700 }); + const owned: string[] = []; + for (let time = 1; time <= 12; time += 1) + owned.push(await ownedDirectory(time)); + // The scan recognizes owned[1]; a second store on the same root then + // removes it before this prune's deletion phase. Nothing was retained, + // so neither a failure nor a pruned event may be logged for it. + lstatTampers.set(owned[1]!, { after: 1, effect: 'enoent' }); + expect(await store.initialize()).toBe(true); + await expect(lstat(owned[0]!)).rejects.toMatchObject({ code: 'ENOENT' }); + expect((await lstat(owned[1]!)).isDirectory()).toBe(true); + expect(log).not.toHaveBeenCalledWith( + 'proactive.monitor_debug_prune_failed', + expect.objectContaining({ directory: owned[1] }), + ); + expect(log).not.toHaveBeenCalledWith( + 'proactive.monitor_debug_pruned', + expect.objectContaining({ directory: owned[1] }), + ); + }); + + it('reports the reason when a stale archive turns unsafe mid-prune', async () => { + await mkdir(root, { mode: 0o700 }); + const owned: string[] = []; + for (let time = 1; time <= 11; time += 1) + owned.push(await ownedDirectory(time)); + // The scan accepts owned[0], then the archive is swapped for a symlink + // before the deletion phase rechecks it. The refusal is a privacy guard, + // not a transient OS delete failure, and must be reported by name. + lstatTampers.set(owned[0]!, { after: 1, effect: 'symlink' }); + expect(await store.initialize()).toBe(true); + expect((await lstat(owned[0]!)).isDirectory()).toBe(true); + expect(log).toHaveBeenCalledWith( + 'proactive.monitor_debug_prune_failed', + expect.objectContaining({ + directory: owned[0], + retained: true, + reason: 'unsafe_directory', + }), + ); + }); + it('rejects shared or symlink archive roots without touching their contents', async () => { await mkdir(root, { mode: 0o700 }); await writeFile(join(root, 'keep.txt'), 'keep'); + // Windows has no POSIX permission bits; a shared-looking mode cannot be + // expressed or rejected there. if (process.platform !== 'win32') { await chmod(root, 0o755); expect(await store.initialize()).toBe(false); @@ -424,6 +591,25 @@ describe('MonitorDebugStore', () => { expect(await readFile(join(root, 'keep.txt'), 'utf8')).toBe('keep'); }); + it('accepts directories on Windows, where POSIX permission bits do not exist', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + // Both READMEs' Windows isolation caveat assumes the default root stays + // inside the OS temporary directory. + expect(new MonitorDebugStore(log).root).toBe( + join(tmpdir(), 'qwen-live-monitor-debug'), + ); + await mkdir(root, { mode: 0o700 }); + // Stand-in for Windows reporting every directory with group/other bits. + await chmod(root, 0o755); + expect(await store.initialize()).toBe(true); + + // Symlink rejection is not platform-gated and must still apply. + const linked = new MonitorDebugStore(log, join(temporary, 'linked-win32')); + stores.push(linked); + await symlink(root, linked.root); + expect(await linked.initialize()).toBe(false); + }); + it('does not recreate an active directory pruned by another store', async () => { const archive = await recorder(); const otherStore = new MonitorDebugStore(log, root); diff --git a/packages/qwen-live/src/proactive/monitor-debug-store.ts b/packages/qwen-live/src/proactive/monitor-debug-store.ts index bf059f415af..6e106b2ffcb 100644 --- a/packages/qwen-live/src/proactive/monitor-debug-store.ts +++ b/packages/qwen-live/src/proactive/monitor-debug-store.ts @@ -200,13 +200,37 @@ export class MonitorDebugStore { ); this.lastCreatedAt = Math.max(this.lastCreatedAt, owned[0]?.createdAt ?? 0); for (const entry of owned.slice(10)) { - for (const recorder of this.recorders) - if (recorder.directory === entry.directory) recorder.evict(); - await privateDirectory(entry.directory); - await rm(entry.directory, { recursive: true, force: true }); - this.emit('proactive.monitor_debug_pruned', { - directory: entry.directory, - }); + try { + for (const recorder of this.recorders) + if (recorder.directory === entry.directory) recorder.evict(); + await privateDirectory(entry.directory); + // Remove the media subtree first and the marker last: a removal that + // fails part-way leaves the archive recognizable, so the next prune + // retries it instead of orphaning it. + await rm(join(entry.directory, 'requests'), { + recursive: true, + force: true, + maxRetries: 3, + }); + await rm(entry.directory, { + recursive: true, + force: true, + maxRetries: 3, + }); + this.emit('proactive.monitor_debug_pruned', { + directory: entry.directory, + }); + } catch (error) { + /* One undeletable archive must not wedge the remaining prune. */ + const code = (error as NodeJS.ErrnoException).code; + // A concurrent pruner on the same root already removed it. + if (code === 'ENOENT') continue; + this.emit('proactive.monitor_debug_prune_failed', { + directory: entry.directory, + retained: true, + reason: code ?? (error instanceof Error ? error.message : 'unknown'), + }); + } } } }