From 7e6641796e23031d6f1f0f6f23f22747758ffcaa Mon Sep 17 00:00:00 2001 From: abraxas914 Date: Sat, 22 Aug 2026 22:57:19 +0800 Subject: [PATCH 1/5] fix(desktop): harden rendered content and remove dead assets --- .../desktop/public/assets/fonts/README.md | 34 - .../public/assets/fonts/azonix-wordmark.woff2 | Bin 6848 -> 0 bytes .../desktop/public/assets/fonts/fonts.css | 21 - .../assets/fonts/jetbrains-mono-latin.woff2 | Bin 31340 -> 0 bytes .../public/assets/fonts/lexend-latin.woff2 | Bin 39692 -> 0 bytes .../public/assets/fonts/noto-sans-latin.woff2 | Bin 35856 -> 0 bytes frontends/desktop/public/i18n.js | 496 --- frontends/desktop/public/phosphor-icons.js | 101 - frontends/desktop/public/styles.css | 2668 ----------------- frontends/desktop/public/vendor/marked.min.js | 6 - .../desktop/scripts/assert-dist-built.mjs | 23 +- .../desktop/scripts/react-public-assets.mjs | 17 + .../desktop/scripts/verify-ci-contract.mjs | 11 +- .../external-link-interceptor.test.ts | 55 +- .../__tests__/rendered-content-policy.test.ts | 87 + .../rendered-content-security.test.tsx | 72 + .../chat/Thread/parts/MarkdownPart.tsx | 10 +- .../Thread/parts/SafeMarkdownComponents.tsx | 40 + .../chat/Thread/parts/SummaryPart.tsx | 11 +- frontends/desktop/src/lib/katex-memo.ts | 9 +- .../src/lib/rendered-content-policy.ts | 149 + frontends/desktop/src/main.tsx | 20 +- 22 files changed, 469 insertions(+), 3361 deletions(-) delete mode 100644 frontends/desktop/public/assets/fonts/README.md delete mode 100644 frontends/desktop/public/assets/fonts/azonix-wordmark.woff2 delete mode 100644 frontends/desktop/public/assets/fonts/fonts.css delete mode 100644 frontends/desktop/public/assets/fonts/jetbrains-mono-latin.woff2 delete mode 100644 frontends/desktop/public/assets/fonts/lexend-latin.woff2 delete mode 100644 frontends/desktop/public/assets/fonts/noto-sans-latin.woff2 delete mode 100644 frontends/desktop/public/i18n.js delete mode 100644 frontends/desktop/public/phosphor-icons.js delete mode 100644 frontends/desktop/public/styles.css delete mode 100644 frontends/desktop/public/vendor/marked.min.js create mode 100644 frontends/desktop/scripts/react-public-assets.mjs create mode 100644 frontends/desktop/src/__tests__/rendered-content-policy.test.ts create mode 100644 frontends/desktop/src/__tests__/rendered-content-security.test.tsx create mode 100644 frontends/desktop/src/components/chat/Thread/parts/SafeMarkdownComponents.tsx create mode 100644 frontends/desktop/src/lib/rendered-content-policy.ts diff --git a/frontends/desktop/public/assets/fonts/README.md b/frontends/desktop/public/assets/fonts/README.md deleted file mode 100644 index f8e5a57e8..000000000 --- a/frontends/desktop/public/assets/fonts/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# Desktop Font Assets - -These font files are the offline Latin bundle for the vanilla desktop shell. - -## Files - -- `azonix-wordmark.woff2` - - Role: brand wordmark only - - Source: converted on 2026-05-25 from the local owner-installed file at `~/Library/Fonts/azonix/Azonix.otf` - - License: owner-provided / verify before external redistribution - - Note: this repo currently treats Azonix as a project-local brand asset - -- `lexend-latin.woff2` - - Role: English titles and navigation - - Source: Google Fonts CSS2 API, specimen page - - Downloaded: 2026-05-25 - - License: SIL Open Font License 1.1 - -- `noto-sans-latin.woff2` - - Role: English body copy and default Latin UI text - - Source: Google Fonts CSS2 API, specimen page - - Downloaded: 2026-05-25 - - License: SIL Open Font License 1.1 - -- `jetbrains-mono-latin.woff2` - - Role: config/value dense controls and numeric surfaces - - Source: Google Fonts CSS2 API, specimen page - - Downloaded: 2026-05-25 - - License: SIL Open Font License 1.1 - -## Notes - -- Only Latin subsets are bundled here. Chinese UI text continues to use the existing system fallback stack. -- Runtime does not fetch fonts from a CDN. `frontends/desktop/static/assets/fonts/fonts.css` is the only font entrypoint for the vanilla shell. diff --git a/frontends/desktop/public/assets/fonts/azonix-wordmark.woff2 b/frontends/desktop/public/assets/fonts/azonix-wordmark.woff2 deleted file mode 100644 index 947bfbebca88ae495253f19f8b679b6929e55dd3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6848 zcmV;x8b9TCPew9NR8&s@02;sm3;+NC052>602*=t0RR9100000000000000000000 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∓Jw5)|1+k+LPLo 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 diff --git a/frontends/desktop/public/assets/fonts/noto-sans-latin.woff2 b/frontends/desktop/public/assets/fonts/noto-sans-latin.woff2 deleted file mode 100644 index e082930455df9ca4195fa8274dbb1a9c1546d640..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 35856 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 diff --git a/frontends/desktop/public/i18n.js b/frontends/desktop/public/i18n.js deleted file mode 100644 index 0c90a9f9e..000000000 --- a/frontends/desktop/public/i18n.js +++ /dev/null @@ -1,496 +0,0 @@ -(() => { - const I18N = { - zh: { - 'app.title': 'GenericAgent 桌面版', - 'brand.sub': '桌面终端', - 'nav.chat': '聊天', 'nav.services': '后台服务', 'nav.channels': '消息通道', 'nav.status': '状态面板', - 'nav.collab': '指挥家', 'nav.token': '用量', - 'foot.settings': '配置', 'foot.ver': 'GenericAgent · 桌面版', - 'chat.startTitle': '开始对话', 'chat.startSub': '直接输入,或点预设功能一键启动', - 'preset.butler.t': '指挥家', 'preset.butler.d': '复杂任务自动拆解,只需查看进度和简报', - 'preset.plan.t': 'Plan 模式', 'preset.plan.d': '加载 Plan SOP,按探索→规划→执行→验证流程', - 'preset.goal.t': 'Goal 模式', 'preset.goal.d': '设定目标,自主完成', - 'preset.autonomous.t': '自主行动', 'preset.autonomous.d': '按 SOP 规划/执行任务,产出报告(reflect/autonomous.py 同源)', - 'preset.hive.t': 'Hive 协作', 'preset.hive.d': '多 worker 协同攻坚', - 'preset.review.t': '深度复核', 'preset.review.d': '挑刺式质量把关', - 'preset.findwork.t': '找点事做', 'preset.findwork.d': '分析当前情况,推荐一批让你感兴趣的 TODO', - 'preset.mine.t': '我的·周报', 'preset.mine.d': '自定义:抓本周提交并写周报', - 'preset.add.t': '自定义', 'preset.add.d': '任意一句话存为功能', - 'composer.placeholder': 'GA 能帮你做些什么?', - 'search.placeholder': '搜索会话…', 'conv.new': '新对话', - 'ctx.pin': '置顶', 'ctx.unpin': '取消置顶', 'ctx.rename': '重命名', 'ctx.del': '删除', - 'common.close': '关闭', 'common.more': '更多', 'common.optional': '选填', 'common.save': '保存', - 'modal.preset': '预设功能', 'modal.addModel': '添加模型', 'modal.editModel': '编辑模型', 'modal.settings': '配置', - 'modal.customPreset': '自定义预设', - 'modal.editCustomPreset': '编辑任务', - 'customPreset.titlePh': '标题,例如「写周报」', - 'customPreset.promptPh': 'Prompt 内容,发送时会作为消息提交', - 'customPreset.empty': '标题和 Prompt 不能为空', - 'customPreset.removeTitle': '删除', - 'customPreset.editTitle': '编辑', - 'builtinPreset.restoreBtn': '恢复默认预设', - 'set.appearance': '外观', 'set.plainUi': '素色', 'set.fontSize': '聊天字号', 'set.lang': '语言', 'set.model': '模型', 'set.addModel': '添加模型', 'set.features': '功能', 'set.importMykey': '导入已有模型配置(mykey.py)', 'set.exportMykey': '导出当前模型配置', 'set.importMemory': '导入已有记忆与会话记录(选择 GenericAgent 根目录)', 'set.gaSource': '接入独立 GenericAgent 源码(把桌面版当作壳)', 'set.gaSourceClear': '取消接入,改用内置版本', 'set.gaSourceCurrent': '当前接入', 'set.serviceManager': '后台服务管理', - 'shortcut.askConfirm': '是否在桌面创建 GenericAgent 快捷方式?', - 'appearance.light': '浅色', 'appearance.dark': '深色', - 'set.noModels': '暂无模型,点击下方添加', - 'lang.zh': '简体中文', 'lang.en': 'English', - 'model.name': '备注', 'model.namePh': '会显示在模型列表', - 'model.apikey': 'API Key', 'model.apikeyPh': 'sk-...', 'model.apikeyKeep': '留空则保持原 Key 不变', - 'model.apibase': 'API 地址', 'model.apibasePh': 'https://.../v1/messages', - 'model.protocol': '协议', 'model.protocolPick': '请选择…', 'model.protocolOai': 'OpenAI 兼容 (chat/completions)', 'model.protocolClaude': 'Anthropic (Claude /v1/messages)', - 'model.stream': '响应方式', 'model.streamOn': '流式', 'model.streamOff': '非流式', - 'model.model': '模型', 'model.modelPh': 'model 参数名', - 'model.modelHint': '须与中转站/官方文档中的 model 字段完全一致', - 'model.retries': '重试 (次)', 'model.connTimeout': '连接超时 (s)', 'model.readTimeout': '读取超时 (s)', - 'model.save': '保存', 'common.cancel': '取消', 'common.confirm': '确认', 'common.edit': '编辑', 'common.delete': '删除', - 'pq.title': '快速接入官方模型', 'pq.sub': '填好 API Key 即可使用', 'pq.toggle': '展开 / 收起', - 'pq.deepseekDesc': '官方 API · OpenAI 兼容', 'pq.qwenDesc': '通义千问 · 阿里云百炼', - 'guide.step1': '点击下方链接,登录后创建并复制 API Key', - 'guide.step2': '把 Key 粘贴到下方「API Key」输入框', - 'guide.step3': '点击保存,即可在模型列表中选用', - 'guide.prefillTip': '已为你预填 API 地址、协议与模型,可按需修改', - 'guide.getKey': '获取 {name} 的 API Key', 'guide.copy': '复制链接', 'guide.copied': '链接已复制', - 'err.modelSave': '保存失败', 'err.modelSwitch': '切换模型失败', 'err.modelRequired': '请填写模型、API Key 和 API 地址', - 'err.modelDelete': '删除失败', 'err.modelDeleteLast': '至少保留一个模型', - 'confirm.modelDelete': '确定删除该模型配置?', - 'model.aggregation': '渠道组(自动故障转移)', 'model.aggregationShort': '渠道组', 'model.aggregationDesc': '按顺序尝试,失败自动切换到下一个', - 'model.emptyMixin': '尚未加入模型', - 'model.addToMixin': '加入渠道组', 'model.inMixin': '已在渠道组', 'model.removeFromMixin': '移出渠道组', 'model.alreadyInMixin': '已在渠道组中', 'model.dragReorder': '拖拽调整顺序', - 'err.mixinFailed': '操作失败', - 'page.services.title': '后台服务', 'page.services.sub': 'IM 消息通道与后台进程,集中查看、启停与日志', - 'page.channels.title': '消息通道', 'page.channels.sub': '后台 IM 进程:列表、启停与日志(同 hub.pyw)', - 'page.status.title': '状态面板', 'page.status.sub': 'hub.pyw 管理的后台进程/服务,集中查看与启停', - 'page.collab.title': '指挥家', 'page.collab.sub': '交代目标,自动拆活与跟进', - 'collab.progressTitle': '分工进度', - 'collab.progressEmpty': '还没有任务在执行。告诉指挥家你的目标后,这里会显示拆分后的处理进度。', - 'collab.placeholder': '请对指挥家描述你想完成的目标', - 'collab.guideTitle': '把要完成的事告诉指挥家', - 'collab.guideWhen': '适合需要多步处理、要花一些时间才能完成的目标。日常聊天和快问快答,请用左侧「聊天」。', - 'collab.guideStep1t': '描述目标', - 'collab.guideStep1d': '在聊天框里写下你想做的事,发给指挥家', - 'collab.guideStep2t': '自动拆解', - 'collab.guideStep2d': '指挥家自动拆解、分配任务,实时监督和调度', - 'collab.guideStep3t': '交付摘要', - 'collab.guideStep3d': '指挥家根据执行状态,呈上任务简报', - 'collab.guideStep4t': '随时调整', - 'collab.guideStep4d': '随时补充要求或细节,指挥家都会处理', - 'collab.chipProgress': '现在进展如何?', - 'collab.chipPause': '先暂停当前任务', - 'collab.chipSummary': '总结一下目前的结果', - 'collab.showProgressTitle': '查看分工进度', - 'collab.statRunning': '进行中', - 'collab.statDone': '已完成', - 'collab.plusMenu': '更多操作', - 'collab.switchMode': '切换模式', - 'collab.typing': '指挥家正在处理', - 'collab.offline': '无法连接指挥家服务,请确认后端已启动。', - 'collab.retry': '重试', - 'collab.reconnect': '连接断开,正在重连… 已保留上次任务进度。', - 'collab.reconnectIn': '{n} 秒后重试', - 'collab.stRunning': '执行中', 'collab.stReported': '已回报', 'collab.stPaused': '已暂停', - 'collab.stFailed': '遇到问题', 'collab.stTerminated': '已终止', - 'collab.summaryRunning': '正在处理中…', 'collab.summaryWait': '等待回报', - 'collab.taskFallback': '任务 {n}', - 'collab.timeJust': '刚刚', - 'collab.timeSec': '{n} 秒前', - 'collab.timeMin': '{n} 分钟前', - 'collab.timeHr': '{n} 小时前', - 'collab.timeDay': '{n} 天前', - 'page.token.title': '用量', 'page.token.sub': '每会话与累计用量及缓存率', - 'status.connecting': '正在连接…', 'status.ready': '服务在线', 'status.running': '处理中', - 'status.disconnected': '服务离线', 'status.stopped': '已停止', 'status.idle': '待命', - 'conv.emptyList': '暂无会话,点「+ 新对话」开始', 'conv.defaultTitle': '新对话', - 'err.bridge': '服务未响应', 'err.newSession': '新建会话失败', 'err.poll': '轮询失败', 'err.stop': '停止失败', - 'err.interruptTimeout': '等待上一轮停止超时,请稍后再试', - 'sys.interruptPrev.hint': '已停止上一轮,正在处理新消息', - 'chat.interrupting': '正在停止上一轮…', - 'chat.sessionLoading': '正在加载会话…', - 'sys.stopRequested': '已请求停止', - 'slash.help': '可用命令:\n/new 新会话 /clear 清屏 /stop 停止 /settings 设置', - 'slash.unknown': '未知命令', - 'upload.hint': '上传文件:选择 / 拖拽 / 粘贴', - 'upload.button': '上传文件', - 'upload.tooLarge': '文件过大或数量超限', 'upload.empty': '跳过空文件', - 'upload.failed': '上传失败', - 'err.charLimit': '已达字数上限({n}),发送时将自动截断', 'err.charLimitReached': '已达字数上限({n})', 'err.numMax': '不能超过 {n}', - 'file.openFailed': '无法打开文件', - 'file.kindGeneric': '文件', - 'file.kindDoc': '文档', - 'file.kindSheet': '表格', - 'file.kindSlide': '幻灯片', - 'file.kindCode': '代码', - 'file.kindArchive': '压缩包', - 'file.kindAudio': '音频', - 'file.kindVideo': '视频', - 'upload.removeTitle': '移除', - 'upload.dropHint': '松开以上传文件', - 'lightbox.closeTitle': '关闭', - 'fold.thinking': '思考', 'fold.tool': '工具调用', 'fold.toolResult': '工具结果', 'fold.llm': 'LLM Running', 'fold.turn': '第 {n} 轮', - 'plan.header': '计划 ({done}/{total})', 'plan.complete': '✓ 计划完成 ({n}/{n})', - 'plan.running': '计划执行中', 'plan.completeTitle': '计划完成', - 'plan.placeholder': '计划模式已激活', 'plan.waiting': '等待写入 {path} …', 'plan.overflow': '还有 {n} 项', - 'plan.current': '当前', 'plan.fold': '折叠', 'plan.collapse': '收起', 'plan.expand': '展开', 'plan.details': '详情', 'plan.dismiss': '不再显示', - 'plan.capsuleRunning': '运行中', 'plan.capsuleComplete': '已完成', - 'timing.elapsed': '已运行 {t}', - 'model.auto': '自动选择', - 'model.menuLabel': '选择模型', - 'chip.plan': 'Plan', - 'chip.auto': 'Auto', - 'ch.wechat': '微信', 'ch.wecom': '企业微信', 'ch.lark': '飞书', 'ch.dingtalk': '钉钉', - 'ch.qq': 'QQ', 'ch.telegram': 'Telegram', 'ch.discord': 'Discord', - 'ch.loading': '加载中…', 'ch.empty': '未发现 IM 进程脚本', - 'ch.logEmpty': '暂无日志', - 'err.channelLoad': '加载失败', 'err.channelStart': '启动失败', 'err.channelStop': '停止失败', - 'err.mykeyImport': '导入模型配置失败', - 'err.mykeyExport': '导出模型配置失败', - 'err.channelNotConfigured': '请先在 mykey.py 中配置该平台', - 'sys.channelStarted': '已启动', 'sys.channelStopped': '已停止', - 'modal.channelLogs': '进程日志', - 'modal.mykeyConfig': 'mykey.py 配置', - 'sys.configSaved': '配置已保存', - 'sys.mykeyImported': '模型配置已导入', - 'sys.mykeyExported': '模型配置已导出', - 'sys.memoryImported': '记忆已导入', - 'err.memoryImport': '导入记忆失败', - 'sys.memoryImportBackup': '原记忆已备份至', - 'sys.memorySessions': '会话', - 'sys.gaSourcePickTitle': '选择要接入的 GenericAgent 源码根目录(含 agentmain.py)', - 'sys.gaSourceSwitching': '正在切换并重启后端...', - 'sys.gaSourceSet': '已接入独立 GenericAgent', - 'sys.gaSourceCleared': '已取消接入,恢复内置版本', - 'err.gaSourceSet': '接入失败', - 'err.gaSourceDesktopOnly': '此功能仅在桌面版中可用', - 'sys.memoryPickTitle': '选择 GenericAgent 根目录(包含 memory 与 temp 的目录)', - 'sys.memoryImportPrompt': '请输入 GenericAgent 根目录的完整路径(包含 memory 与 temp 的目录,而非 memory 文件夹本身):', - 'st.starting': '启动中…', 'st.stopping': '停止中…', 'st.online': '在线', 'st.offline': '离线', 'st.error': '错误', 'st.running': '运行', 'st.abnormal': '异常', 'st.missingDeps': '待安装', - 'act.configure': '配置', 'act.logs': '日志', 'act.restart': '重启', 'act.stop': '停止', 'act.start': '启动', 'act.exit': '退出', 'act.installDeps': '安装依赖', 'act.installing': '安装中…', - 'act.copy': '复制', 'act.copied': '已复制', 'act.copyTex': 'TeX', 'act.send': '发送', - 'sys.depsInstalled': '依赖安装完成', 'err.installDeps': '安装失败', - 'proc.imbotWechat': 'imbot · 微信', 'proc.imbotDing': 'imbot · 钉钉', 'proc.scheduler': '定时任务调度', 'proc.conductor': '指挥家', - 'cm.scheduling': '调度中', 'cm.running': '执行中', 'cm.idleSt': '空闲', - 'cm.master': '已派 3 子任务', 'cm.w1': '子任务:抓取数据', 'cm.w2': '子任务:复核结果', 'cm.sub': '等待派单', - 'tok.total': '累计', 'tok.cost': '缓存率', 'tok.today': '今日', 'tok.tabAll': '聊天', 'tok.tabConductor': '指挥家', 'tok.condTotal': '指挥家累计', 'tok.condCurrent': '指挥家本次', 'tok.condTip': '指挥家消耗不计入聊天累计', 'tok.condOffline': '指挥家服务离线', 'tok.disclaimer': '不同 API 网站的计费价格可能会有差异,请以实际网站为准。', - 'tok.colSession': '会话', 'tok.colIn': '输入', 'tok.colOut': '输出', 'tok.colCacheW': '缓存写入', 'tok.colCache': '缓存读取', 'tok.colCost': '成本', - 'tok.from': '从', 'tok.to': '到', 'tok.reset': '重置', 'tok.noData': '暂无记录', 'tok.deleted': '此会话已删除', - 'tok.pricingUnknown': '⚠ 此模型计费规则尚未明确,按默认估算', - 'tok.priceInput': '输入: $', 'tok.priceOutput': '输出: $', - 'tok.priceCacheW': '缓存写入: $', 'tok.priceCacheR': '缓存读取: $', - 'presetPrompt.goal': '进入 Goal 模式:读 L3 goal mode SOP,自主达成我接下来描述的目标。', - 'presetPrompt.plan': '进入 Plan 模式:先读 memory/plan_sop.md,按其中「探索→规划→执行→验证」流程,等我接下来描述要做的任务。', - 'presetPrompt.autonomous': '🤖 进入自主行动模式:阅读 memory/autonomous_operation_sop.md,按 SOP 选取或规划任务,独立执行并产出报告。', - 'presetPrompt.hive': '启动 Goal Hive 模式:按 hive SOP 拉起多个 worker 协同完成我接下来的目标。', - 'presetPrompt.review': '进入监察者模式:对刚才的产出严格挑刺、逐项复核并报告问题。', - 'presetPrompt.findwork': '按照自主行动的规划部分,充分分析我的情况,给我生成一批 TODO,务必让我感兴趣。', - 'presetPrompt.mine': '抓取本周的 git 提交并写一份周报。', - 'ask.banner': 'GA 等你回答', - 'ask.replyHint': '在下方输入框回复', - 'ask.placeholderOpen': '在此输入你的回答… (Enter 发送)', - }, - en: { - 'app.title': 'GenericAgent Desktop', - 'brand.sub': 'Desktop terminal', - 'nav.chat': 'Chat', 'nav.services': 'Services', 'nav.channels': 'Channels', 'nav.status': 'Status', - 'nav.collab': 'Conductor', 'nav.token': 'Usage', - 'foot.settings': 'Settings', 'foot.ver': 'GenericAgent · Desktop', - 'chat.startTitle': 'Start a conversation', 'chat.startSub': 'Type a message, or pick a preset', - 'preset.butler.t': 'Conductor', 'preset.butler.d': 'Auto-decompose complex tasks; just check progress and briefings', - 'preset.plan.t': 'Plan mode', 'preset.plan.d': 'Load Plan SOP — explore→plan→execute→verify', - 'preset.goal.t': 'Goal mode', 'preset.goal.d': 'Set a goal, run autonomously', - 'preset.autonomous.t': 'Autonomous mode', 'preset.autonomous.d': 'Plan/execute tasks per SOP and produce reports (same as reflect/autonomous.py)', - 'preset.hive.t': 'Hive', 'preset.hive.d': 'Multi-worker collaboration', - 'preset.review.t': 'Deep review', 'preset.review.d': 'Strict quality check', - 'preset.findwork.t': 'Find me work', 'preset.findwork.d': 'Analyze my context and suggest a batch of interesting TODOs', - 'preset.mine.t': 'My · Weekly', 'preset.mine.d': 'Custom: weekly report from commits', - 'preset.add.t': 'Custom', 'preset.add.d': 'Save any prompt as a function', - 'composer.placeholder': 'What can GA do for you?', - 'search.placeholder': 'Search chats…', 'conv.new': 'New chat', - 'ctx.pin': 'Pin', 'ctx.unpin': 'Unpin', 'ctx.rename': 'Rename', 'ctx.del': 'Delete', - 'common.close': 'Close', 'common.more': 'More', 'common.optional': 'Optional', 'common.save': 'Save', - 'modal.preset': 'Presets', 'modal.addModel': 'Add model', 'modal.editModel': 'Edit model', 'modal.settings': 'Settings', - 'modal.customPreset': 'Custom preset', - 'modal.editCustomPreset': 'Edit task', - 'customPreset.titlePh': 'Title, e.g. "Weekly report"', - 'customPreset.promptPh': 'Prompt body — sent as the message when clicked', - 'customPreset.empty': 'Title and Prompt cannot be empty', - 'customPreset.removeTitle': 'Delete', - 'customPreset.editTitle': 'Edit', - 'builtinPreset.restoreBtn': 'Restore defaults', - 'set.appearance': 'Appearance', 'set.plainUi': 'Plain', 'set.fontSize': 'Chat font size', 'set.lang': 'Language', 'set.model': 'Model', 'set.addModel': 'Add model', 'set.features': 'Features', 'set.importMykey': 'Import model config (mykey.py)', 'set.exportMykey': 'Export current model config', 'set.importMemory': 'Import existing memory & sessions (select GenericAgent root)', 'set.gaSource': 'Connect to a separate GenericAgent source (use desktop as a shell)', 'set.gaSourceClear': 'Disconnect, use the bundled version', 'set.gaSourceCurrent': 'Connected to', 'set.serviceManager': 'Service manager', - 'shortcut.askConfirm': 'Create a desktop shortcut for GenericAgent?', - 'appearance.light': 'Light', 'appearance.dark': 'Dark', - 'set.noModels': 'No models yet — add one below', - 'lang.zh': '简体中文', 'lang.en': 'English', - 'model.name': 'Note', 'model.namePh': 'Shown in the model list', - 'model.apikey': 'API Key', 'model.apikeyPh': 'sk-...', 'model.apikeyKeep': 'Leave blank to keep the current key', - 'model.apibase': 'API base URL', 'model.apibasePh': 'https://.../v1/messages', - 'model.protocol': 'Protocol', 'model.protocolPick': 'Select…', 'model.protocolOai': 'OpenAI-compatible (chat/completions)', 'model.protocolClaude': 'Anthropic (Claude /v1/messages)', - 'model.stream': 'Response', 'model.streamOn': 'Stream', 'model.streamOff': 'Non-stream', - 'model.model': 'Model', 'model.modelPh': 'model parameter name', - 'model.modelHint': 'Must match the model field in your provider docs exactly', - 'model.retries': 'Retries (×)', 'model.connTimeout': 'Connect (s)', 'model.readTimeout': 'Read (s)', - 'model.save': 'Save', 'common.cancel': 'Cancel', 'common.confirm': 'Confirm', 'common.edit': 'Edit', 'common.delete': 'Delete', - 'pq.title': 'Quick connect a model', 'pq.sub': 'Add your API key to get started', 'pq.toggle': 'Expand / collapse', - 'pq.deepseekDesc': 'Official API · OpenAI-compatible', 'pq.qwenDesc': 'Tongyi Qwen · Aliyun Bailian', - 'guide.step1': 'Open the link, sign in, then create & copy your API key', - 'guide.step2': 'Paste the key into the “API Key” field below', - 'guide.step3': 'Click Save — then pick it from the model list', - 'guide.prefillTip': 'API base, protocol and model are pre-filled — edit if needed', - 'guide.getKey': 'Get your {name} API key', 'guide.copy': 'Copy link', 'guide.copied': 'Link copied', - 'err.modelSave': 'Save failed', 'err.modelSwitch': 'Failed to switch model', 'err.modelRequired': 'Model, API Key and base URL are required', - 'err.modelDelete': 'Delete failed', 'err.modelDeleteLast': 'At least one model is required', - 'confirm.modelDelete': 'Delete this model profile?', - 'model.aggregation': 'Channel group (auto failover)', 'model.aggregationShort': 'Channel group', 'model.aggregationDesc': 'Tries in order, switches to the next on failure', - 'model.emptyMixin': 'No models added yet', - 'model.addToMixin': 'Add to channel', 'model.inMixin': 'In channel', 'model.removeFromMixin': 'Remove from channel', 'model.alreadyInMixin': 'Already in the channel', 'model.dragReorder': 'Drag to reorder', - 'err.mixinFailed': 'Operation failed', - 'page.services.title': 'Services', 'page.services.sub': 'IM channels and background processes — view, start/stop, logs', - 'page.channels.title': 'Channels', 'page.channels.sub': 'Background IM processes: list, start/stop, logs (hub.pyw style)', - 'page.status.title': 'Status', 'page.status.sub': 'Background processes/services managed by hub.pyw', - 'page.collab.title': 'Conductor', 'page.collab.sub': 'Describe a goal — split, delegate, and follow up', - 'collab.progressTitle': 'Progress', - 'collab.progressEmpty': 'No tasks running yet. After you describe a goal to Conductor, split tasks will appear here.', - 'collab.placeholder': 'Describe the goal you want to accomplish', - 'collab.guideTitle': 'Tell Conductor what you want done', - 'collab.guideWhen': 'Best for multi-step goals that take a while. For everyday chat and quick questions, use Chat in the sidebar.', - 'collab.guideStep1t': 'Describe your goal', - 'collab.guideStep1d': 'Write what you want done in the chat box and send it to Conductor', - 'collab.guideStep2t': 'Auto breakdown', - 'collab.guideStep2d': 'Conductor breaks down, assigns, monitors, and coordinates', - 'collab.guideStep3t': 'Summary', - 'collab.guideStep3d': 'Conductor delivers a briefing based on execution status', - 'collab.guideStep4t': 'Adjust anytime', - 'collab.guideStep4d': 'Add requirements or details anytime — Conductor handles them', - 'collab.chipProgress': 'How is it going?', - 'collab.chipPause': 'Pause current tasks', - 'collab.chipSummary': 'Summarize progress so far', - 'collab.showProgressTitle': 'View task progress', - 'collab.statRunning': 'Running', - 'collab.statDone': 'Done', - 'collab.plusMenu': 'More actions', - 'collab.switchMode': 'Switch mode', - 'collab.typing': 'Conductor is working', - 'collab.offline': 'Cannot reach the service. Make sure the backend is running.', - 'collab.retry': 'Retry', - 'collab.reconnect': 'Disconnected — reconnecting… Your last progress is kept.', - 'collab.reconnectIn': 'Retry in {n}s', - 'collab.stRunning': 'Running', 'collab.stReported': 'Reported', 'collab.stPaused': 'Paused', - 'collab.stFailed': 'Issue', 'collab.stTerminated': 'Ended', - 'collab.summaryRunning': 'Working…', 'collab.summaryWait': 'Awaiting report', - 'collab.taskFallback': 'Task {n}', - 'collab.timeJust': 'just now', - 'collab.timeSec': '{n}s ago', - 'collab.timeMin': '{n}m ago', - 'collab.timeHr': '{n}h ago', - 'collab.timeDay': '{n}d ago', - 'page.token.title': 'Usage', 'page.token.sub': 'Per-session and total usage & cache rate', - 'status.connecting': 'Connecting…', 'status.ready': 'Service online', 'status.running': 'Working…', - 'status.disconnected': 'Service offline', 'status.stopped': 'Stopped', 'status.idle': 'Standby', - 'conv.emptyList': 'No chats yet — click “+ New chat”', 'conv.defaultTitle': 'New chat', - 'err.bridge': 'Service not responding', 'err.newSession': 'Failed to create session', 'err.poll': 'Polling failed', 'err.stop': 'Stop failed', - 'err.interruptTimeout': 'Timed out waiting for the previous reply to stop — try again', - 'sys.interruptPrev.hint': 'Previous reply stopped — processing new message', - 'chat.interrupting': 'Stopping previous reply…', - 'chat.sessionLoading': 'Loading conversation…', - 'sys.stopRequested': 'Stop requested', - 'slash.help': 'Commands:\n/new new chat /clear clear /stop stop /settings settings', - 'slash.unknown': 'Unknown command', - 'upload.hint': 'Upload file: pick / drag / paste', - 'upload.button': 'Upload file', - 'upload.tooLarge': 'File too large or limit reached', 'upload.empty': 'Skipped empty file', - 'upload.failed': 'Upload failed', - 'err.charLimit': 'Character limit reached ({n}), text will be truncated on send', 'err.charLimitReached': 'Character limit reached ({n})', 'err.numMax': 'Cannot exceed {n}', - 'file.openFailed': 'Cannot open file', - 'file.kindGeneric': 'File', - 'file.kindDoc': 'Document', - 'file.kindSheet': 'Spreadsheet', - 'file.kindSlide': 'Slides', - 'file.kindCode': 'Code', - 'file.kindArchive': 'Archive', - 'file.kindAudio': 'Audio', - 'file.kindVideo': 'Video', - 'upload.removeTitle': 'Remove', - 'upload.dropHint': 'Drop to upload files', - 'lightbox.closeTitle': 'Close', - 'fold.thinking': 'Thinking', 'fold.tool': 'Tool call', 'fold.toolResult': 'Tool result', 'fold.llm': 'LLM Running', 'fold.turn': 'Turn {n}', - 'plan.header': 'Plan ({done}/{total})', 'plan.complete': '✓ Plan complete ({n}/{n})', - 'plan.running': 'Running plan', 'plan.completeTitle': 'Plan complete', - 'plan.placeholder': 'Plan mode activated', 'plan.waiting': 'waiting for {path} …', 'plan.overflow': '+{n} more', - 'plan.current': 'Now', 'plan.fold': 'Fold', 'plan.collapse': 'Collapse', 'plan.expand': 'Expand', 'plan.details': 'Details', 'plan.dismiss': 'Hide', - 'plan.capsuleRunning': 'Running', 'plan.capsuleComplete': 'Done', - 'timing.elapsed': 'Elapsed {t}', - 'model.auto': 'Auto', - 'model.menuLabel': 'Select model', - 'chip.plan': 'Plan', - 'chip.auto': 'Auto', - 'ch.wechat': 'WeChat', 'ch.wecom': 'WeCom', 'ch.lark': 'Lark', 'ch.dingtalk': 'DingTalk', - 'ch.qq': 'QQ', 'ch.telegram': 'Telegram', 'ch.discord': 'Discord', - 'ch.loading': 'Loading…', 'ch.empty': 'No IM process scripts found', - 'ch.logEmpty': 'No log output yet', - 'err.channelLoad': 'Failed to load', 'err.channelStart': 'Start failed', 'err.channelStop': 'Stop failed', - 'err.mykeyImport': 'Failed to import model config', - 'err.mykeyExport': 'Failed to export model config', - 'err.channelNotConfigured': 'Configure this platform in mykey.py first', - 'sys.channelStarted': 'Started', 'sys.channelStopped': 'Stopped', - 'modal.channelLogs': 'Process logs', - 'modal.mykeyConfig': 'mykey.py', - 'sys.configSaved': 'Configuration saved', - 'sys.mykeyImported': 'Model config imported', - 'sys.mykeyExported': 'Model config exported', - 'sys.memoryImported': 'Memory imported', - 'err.memoryImport': 'Failed to import memory', - 'sys.memoryImportBackup': 'Previous memory backed up to', - 'sys.memorySessions': 'sessions', - 'sys.gaSourcePickTitle': 'Select the GenericAgent source root to connect to (contains agentmain.py)', - 'sys.gaSourceSwitching': 'Switching and restarting the backend...', - 'sys.gaSourceSet': 'Connected to the external GenericAgent', - 'sys.gaSourceCleared': 'Disconnected, restored the bundled version', - 'err.gaSourceSet': 'Failed to connect', - 'err.gaSourceDesktopOnly': 'This feature is only available in the desktop app', - 'sys.memoryPickTitle': 'Select the GenericAgent root directory (the folder containing memory and temp)', - 'sys.memoryImportPrompt': 'Enter the full path of the GenericAgent root directory (the folder containing memory and temp, not the memory folder itself):', - 'st.starting': 'Starting…', 'st.stopping': 'Stopping…', 'st.online': 'Online', 'st.offline': 'Offline', 'st.error': 'Error', 'st.running': 'Running', 'st.abnormal': 'Error', 'st.missingDeps': 'Needs Setup', - 'act.configure': 'Configure', 'act.logs': 'Logs', 'act.restart': 'Restart', 'act.stop': 'Stop', 'act.start': 'Start', 'act.exit': 'Exit', 'act.installDeps': 'Install', 'act.installing': 'Installing…', - 'act.copy': 'Copy', 'act.copied': 'Copied', 'act.copyTex': 'TeX', 'act.send': 'Send', - 'sys.depsInstalled': 'Dependencies installed', 'err.installDeps': 'Install failed', - 'proc.imbotWechat': 'imbot · WeChat', 'proc.imbotDing': 'imbot · DingTalk', 'proc.scheduler': 'Scheduler', 'proc.conductor': 'Conductor', - 'cm.scheduling': 'Scheduling', 'cm.running': 'Running', 'cm.idleSt': 'Idle', - 'cm.master': 'Dispatched 3 subtasks', 'cm.w1': 'Subtask: fetch data', 'cm.w2': 'Subtask: review results', 'cm.sub': 'Waiting for tasks', - 'tok.total': 'Total', 'tok.cost': 'Cache rate', 'tok.today': 'Today', 'tok.tabAll': 'Chat', 'tok.tabConductor': 'Conductor', 'tok.condTotal': 'Conductor Total', 'tok.condCurrent': 'Conductor Current', 'tok.condTip': 'Conductor usage is not included in chat totals', 'tok.condOffline': 'Service offline', 'tok.disclaimer': 'Pricing may vary by API provider. Please refer to the actual website.', - 'tok.colSession': 'Session', 'tok.colIn': 'Input', 'tok.colOut': 'Output', 'tok.colCacheW': 'Cache write', 'tok.colCache': 'Cache read', 'tok.colCost': 'Cost', - 'tok.from': 'From', 'tok.to': 'To', 'tok.reset': 'Reset', 'tok.noData': 'No records', 'tok.deleted': 'Session deleted', - 'tok.pricingUnknown': '⚠ Pricing not confirmed, using defaults', - 'tok.priceInput': 'Input: $', 'tok.priceOutput': 'Output: $', - 'tok.priceCacheW': 'Cache write: $', 'tok.priceCacheR': 'Cache read: $', - 'presetPrompt.goal': 'Enter Goal mode: read the L3 goal-mode SOP and autonomously achieve the goal I describe next.', - 'presetPrompt.plan': 'Enter Plan mode: first read memory/plan_sop.md, follow its explore→plan→execute→verify flow, and wait for the task I describe next.', - 'presetPrompt.autonomous': '🤖 Enter autonomous mode: read memory/autonomous_operation_sop.md, follow the SOP to pick or plan a task, execute independently, and produce a report.', - 'presetPrompt.hive': 'Start Goal Hive mode: per the hive SOP, spawn multiple workers to collaboratively achieve the goal I describe next.', - 'presetPrompt.review': 'Enter reviewer mode: strictly scrutinize the previous output, review item by item and report issues.', - 'presetPrompt.findwork': 'Following the autonomous planning section, analyze my situation thoroughly and generate a batch of TODOs that genuinely interest me.', - 'presetPrompt.mine': 'Collect this week\'s git commits and write a weekly report.', - 'ask.banner': 'GA is waiting for your answer', - 'ask.replyHint': 'Reply in the input below', - 'ask.placeholderOpen': 'Type your answer here… (Enter to send)', - }, -}; - - const BOOT_I18N = { - zh: { - 'loading.starting_app': '正在启动 GenericAgent...', - 'loading.start': '首次运行:正在准备运行环境...', - 'loading.venv': '正在创建运行环境...', - 'loading.deps': '正在安装依赖...', - 'loading.done': '依赖安装完成', - 'loading.starting': '正在启动服务...', - 'setup.title': 'GenericAgent - 设置', - 'setup.heading': '需要设置', - 'setup.backend_failed': '后端未能启动,请配置下方路径。', - 'setup.prepare_error': '安装失败,错误详情:', - 'setup.py_label': 'Python 解释器路径', - 'setup.py_placeholder': 'python / 解释器路径', - 'setup.py_hint': '例如 C:/Python312/python.exe 或 ~/miniconda3/envs/myenv/bin/python', - 'setup.project_label': '项目目录', - 'setup.project_placeholder': 'GenericAgent 文件夹路径', - 'setup.project_hint': '包含 frontends/desktop_bridge.py 的文件夹', - 'setup.start': '启动', - 'setup.err_py': '请输入 Python 路径', - 'setup.err_project': '请输入项目目录', - 'setup.starting_bridge': '正在启动 bridge...', - 'setup.connected': '已连接,正在打开主窗口...', - 'setup.failed': '启动失败:{error}', - 'setup.limit': '已达字符数上限({n})' - }, - en: { - 'loading.starting_app': 'Starting GenericAgent...', - 'loading.start': 'First run: preparing the runtime...', - 'loading.venv': 'Creating the runtime environment...', - 'loading.deps': 'Installing dependencies...', - 'loading.done': 'Dependencies installed', - 'loading.starting': 'Starting services...', - 'setup.title': 'GenericAgent - Setup', - 'setup.heading': 'Setup Required', - 'setup.backend_failed': 'The backend could not start. Please configure the paths below.', - 'setup.prepare_error': 'Setup failed. Details:', - 'setup.py_label': 'Python interpreter path', - 'setup.py_placeholder': 'python / path to interpreter', - 'setup.py_hint': 'e.g. C:/Python312/python.exe or ~/miniconda3/envs/myenv/bin/python', - 'setup.project_label': 'Project directory', - 'setup.project_placeholder': 'path to GenericAgent folder', - 'setup.project_hint': 'The folder containing frontends/desktop_bridge.py', - 'setup.start': 'Start', - 'setup.err_py': 'Please enter Python path', - 'setup.err_project': 'Please enter project directory', - 'setup.starting_bridge': 'Starting bridge...', - 'setup.connected': 'Connected. Opening the main window...', - 'setup.failed': 'Failed: {error}', - 'setup.limit': 'Character limit reached ({n})' - } -}; - - for (const [code, messages] of Object.entries(BOOT_I18N)) { - I18N[code] = Object.assign(I18N[code] || {}, messages); - } - - const LANGS = Object.freeze(Object.keys(I18N)); - - function normalizeLang(code) { - return LANGS.includes(code) ? code : 'zh'; - } - - function browserLang() { - return (navigator.language || '').toLowerCase().startsWith('zh') ? 'zh' : 'en'; - } - - function bootLang() { - try { - const saved = localStorage.getItem('ga_lang'); - if (LANGS.includes(saved)) return saved; - } catch (_) {} - return browserLang(); - } - - function tFor(lang, key, vars) { - const active = normalizeLang(lang); - let text = (I18N[active] && I18N[active][key]) || (I18N.zh && I18N.zh[key]) || (I18N.en && I18N.en[key]) || key; - if (vars) { - for (const [name, value] of Object.entries(vars)) { - text = text.replaceAll(`{${name}}`, String(value)); - } - } - return text; - } - - function apply(root, activeLang) { - const lang = normalizeLang(activeLang || bootLang()); - const scope = root || document; - if (scope === document) document.documentElement.lang = lang === 'en' ? 'en' : 'zh-CN'; - scope.querySelectorAll('[data-i18n]').forEach(el => { - el.textContent = tFor(lang, el.dataset.i18n); - }); - scope.querySelectorAll('[data-i18n-ph]').forEach(el => { - el.setAttribute('data-ph', tFor(lang, el.dataset.i18nPh)); - }); - scope.querySelectorAll('[data-i18n-placeholder]').forEach(el => { - el.setAttribute('placeholder', tFor(lang, el.dataset.i18nPlaceholder)); - }); - scope.querySelectorAll('[data-i18n-title]').forEach(el => { - el.setAttribute('title', tFor(lang, el.dataset.i18nTitle)); - el.setAttribute('aria-label', tFor(lang, el.dataset.i18nTitle)); - }); - } - - window.GA_I18N = { - dict: I18N, - languages: LANGS, - t: tFor, - apply, - bootLang, - browserLang, - }; - - window.GA_BOOT_I18N = { - get lang() { return bootLang(); }, - t(key, vars) { return tFor(bootLang(), key, vars); }, - apply(root) { return apply(root || document, bootLang()); }, - }; -})(); diff --git a/frontends/desktop/public/phosphor-icons.js b/frontends/desktop/public/phosphor-icons.js deleted file mode 100644 index 101df095b..000000000 --- a/frontends/desktop/public/phosphor-icons.js +++ /dev/null @@ -1,101 +0,0 @@ -(() => { - const PATHS = { - chatTeardropText: - 'M172,112a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h68A8,8,0,0,1,172,112Zm-8,24H96a8,8,0,0,0,0,16h68a8,8,0,0,0,0-16Zm68-12A100.11,100.11,0,0,1,132,224H48a16,16,0,0,1-16-16V124a100,100,0,0,1,200,0Zm-16,0a84,84,0,0,0-168,0v84h84A84.09,84.09,0,0,0,216,124Z', - broadcast: - 'M128,88a40,40,0,1,0,40,40A40,40,0,0,0,128,88Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,152Zm73.71,7.14a80,80,0,0,1-14.08,22.2,8,8,0,0,1-11.92-10.67,63.95,63.95,0,0,0,0-85.33,8,8,0,1,1,11.92-10.67,80.08,80.08,0,0,1,14.08,84.47ZM69,103.09a64,64,0,0,0,11.26,67.58,8,8,0,0,1-11.92,10.67,79.93,79.93,0,0,1,0-106.67A8,8,0,1,1,80.29,85.34,63.77,63.77,0,0,0,69,103.09ZM248,128a119.58,119.58,0,0,1-34.29,84,8,8,0,1,1-11.42-11.2,103.9,103.9,0,0,0,0-145.56A8,8,0,1,1,213.71,44,119.58,119.58,0,0,1,248,128ZM53.71,200.78A8,8,0,1,1,42.29,212a119.87,119.87,0,0,1,0-168,8,8,0,1,1,11.42,11.2,103.9,103.9,0,0,0,0,145.56Z', - chartBar: - 'M224,200h-8V40a8,8,0,0,0-8-8H152a8,8,0,0,0-8,8V80H96a8,8,0,0,0-8,8v40H48a8,8,0,0,0-8,8v64H32a8,8,0,0,0,0,16H224a8,8,0,0,0,0-16ZM160,48h40V200H160ZM104,96h40V200H104ZM56,144H88v56H56Z', - usersThree: - 'M244.8,150.4a8,8,0,0,1-11.2-1.6A51.6,51.6,0,0,0,192,128a8,8,0,0,1-7.37-4.89,8,8,0,0,1,0-6.22A8,8,0,0,1,192,112a24,24,0,1,0-23.24-30,8,8,0,1,1-15.5-4A40,40,0,1,1,219,117.51a67.94,67.94,0,0,1,27.43,21.68A8,8,0,0,1,244.8,150.4ZM190.92,212a8,8,0,1,1-13.84,8,57,57,0,0,0-98.16,0,8,8,0,1,1-13.84-8,72.06,72.06,0,0,1,33.74-29.92,48,48,0,1,1,58.36,0A72.06,72.06,0,0,1,190.92,212ZM128,176a32,32,0,1,0-32-32A32,32,0,0,0,128,176ZM72,120a8,8,0,0,0-8-8A24,24,0,1,1,87.24,82a8,8,0,1,0,15.5-4A40,40,0,1,0,37,117.51,67.94,67.94,0,0,0,9.6,139.19a8,8,0,1,0,12.8,9.61A51.6,51.6,0,0,1,64,128,8,8,0,0,0,72,120Z', - coins: - 'M184,89.57V84c0-25.08-37.83-44-88-44S8,58.92,8,84v40c0,20.89,26.25,37.49,64,42.46V172c0,25.08,37.83,44,88,44s88-18.92,88-44V132C248,111.3,222.58,94.68,184,89.57ZM232,132c0,13.22-30.79,28-72,28-3.73,0-7.43-.13-11.08-.37C170.49,151.77,184,139,184,124V105.74C213.87,110.19,232,122.27,232,132ZM72,150.25V126.46A183.74,183.74,0,0,0,96,128a183.74,183.74,0,0,0,24-1.54v23.79A163,163,0,0,1,96,152,163,163,0,0,1,72,150.25Zm96-40.32V124c0,8.39-12.41,17.4-32,22.87V123.5C148.91,120.37,159.84,115.71,168,109.93ZM96,56c41.21,0,72,14.78,72,28s-30.79,28-72,28S24,97.22,24,84,54.79,56,96,56ZM24,124V109.93c8.16,5.78,19.09,10.44,32,13.57v23.37C36.41,141.4,24,132.39,24,124Zm64,48v-4.17c2.63.1,5.29.17,8,.17,3.88,0,7.67-.13,11.39-.35A121.92,121.92,0,0,0,120,171.41v23.46C100.41,189.4,88,180.39,88,172Zm48,26.25V174.4a179.48,179.48,0,0,0,24,1.6,183.74,183.74,0,0,0,24-1.54v23.79a165.45,165.45,0,0,1-48,0Zm64-3.38V171.5c12.91-3.13,23.84-7.79,32-13.57V172C232,180.39,219.59,189.4,200,194.87Z', - sidebarSimple: - 'M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40ZM40,56H80V200H40ZM216,200H96V56H216V200Z', - pencilSimple: - 'M227.31,73.37,182.63,28.68a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31L227.31,96a16,16,0,0,0,0-22.63ZM92.69,208H48V163.31l88-88L180.69,120ZM192,108.68,147.31,64l24-24L216,84.68Z', - magnifyingGlass: - 'M229.66,218.34l-50.07-50.06a88.11,88.11,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z', - books: - 'M231.65,194.55,198.46,36.75a16,16,0,0,0-19-12.39L132.65,34.42a16.08,16.08,0,0,0-12.3,19l33.19,157.8A16,16,0,0,0,169.16,224a16.25,16.25,0,0,0,3.38-.36l46.81-10.06A16.09,16.09,0,0,0,231.65,194.55ZM136,50.15c0-.06,0-.09,0-.09l46.8-10,3.33,15.87L139.33,66Zm6.62,31.47,46.82-10.05,3.34,15.9L146,97.53Zm6.64,31.57,46.82-10.06,13.3,63.24-46.82,10.06ZM216,197.94l-46.8,10-3.33-15.87L212.67,182,216,197.85C216,197.91,216,197.94,216,197.94ZM104,32H56A16,16,0,0,0,40,48V208a16,16,0,0,0,16,16h48a16,16,0,0,0,16-16V48A16,16,0,0,0,104,32ZM56,48h48V64H56Zm0,32h48v96H56Zm48,128H56V192h48v16Z', - gridFour: - 'M200,40H56A16,16,0,0,0,40,56V200a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V56A16,16,0,0,0,200,40Zm0,80H136V56h64ZM120,56v64H56V56ZM56,136h64v64H56Zm144,64H136V136h64v64Z', - robot: - 'M200,48H136V16a8,8,0,0,0-16,0V48H56A32,32,0,0,0,24,80V192a32,32,0,0,0,32,32H200a32,32,0,0,0,32-32V80A32,32,0,0,0,200,48Zm16,144a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V80A16,16,0,0,1,56,64H200a16,16,0,0,1,16,16Zm-52-56H92a28,28,0,0,0,0,56h72a28,28,0,0,0,0-56Zm-24,16v24H116V152ZM80,164a12,12,0,0,1,12-12h8v24H92A12,12,0,0,1,80,164Zm84,12h-8V152h8a12,12,0,0,1,0,24ZM72,108a12,12,0,1,1,12,12A12,12,0,0,1,72,108Zm88,0a12,12,0,1,1,12,12A12,12,0,0,1,160,108Z', - dotsThree: - 'M140,128a12,12,0,1,1-12-12A12,12,0,0,1,140,128Zm56-12a12,12,0,1,0,12,12A12,12,0,0,0,196,116ZM60,116a12,12,0,1,0,12,12A12,12,0,0,0,60,116Z', - folderSimplePlus: - 'M216,72H130.67L102.93,51.2a16.12,16.12,0,0,0-9.6-3.2H40A16,16,0,0,0,24,64V200a16,16,0,0,0,16,16H216.89A15.13,15.13,0,0,0,232,200.89V88A16,16,0,0,0,216,72Zm0,128H40V64H93.33L123.2,86.4A8,8,0,0,0,128,88h88Zm-56-56a8,8,0,0,1-8,8H136v16a8,8,0,0,1-16,0V152H104a8,8,0,0,1,0-16h16V120a8,8,0,0,1,16,0v16h16A8,8,0,0,1,160,144Z', - folderSimple: - 'M216,72H130.67L102.93,51.2a16.12,16.12,0,0,0-9.6-3.2H40A16,16,0,0,0,24,64V200a16,16,0,0,0,16,16H216.89A15.13,15.13,0,0,0,232,200.89V88A16,16,0,0,0,216,72Zm0,128H40V64H93.33L123.2,86.4A8,8,0,0,0,128,88h88Z', - bracketsCurly: - 'M43.18,128a29.78,29.78,0,0,1,8,10.26c4.8,9.9,4.8,22,4.8,33.74,0,24.31,1,36,24,36a8,8,0,0,1,0,16c-17.48,0-29.32-6.14-35.2-18.26-4.8-9.9-4.8-22-4.8-33.74,0-24.31-1-36-24-36a8,8,0,0,1,0-16c23,0,24-11.69,24-36,0-11.72,0-23.84,4.8-33.74C50.68,38.14,62.52,32,80,32a8,8,0,0,1,0,16C57,48,56,59.69,56,84c0,11.72,0,23.84-4.8,33.74A29.78,29.78,0,0,1,43.18,128ZM240,120c-23,0-24-11.69-24-36,0-11.72,0-23.84-4.8-33.74C205.32,38.14,193.48,32,176,32a8,8,0,0,0,0,16c23,0,24,11.69,24,36,0,11.72,0,23.84,4.8,33.74a29.78,29.78,0,0,0,8,10.26,29.78,29.78,0,0,0-8,10.26c-4.8,9.9-4.8,22-4.8,33.74,0,24.31-1,36-24,36a8,8,0,0,0,0,16c17.48,0,29.32-6.14,35.2-18.26,4.8-9.9,4.8-22,4.8-33.74,0-24.31,1-36,24-36a8,8,0,0,0,0-16Z', - gear: - 'M128,80a48,48,0,1,0,48,48A48.05,48.05,0,0,0,128,80Zm0,80a32,32,0,1,1,32-32A32,32,0,0,1,128,160Zm88-29.84q.06-2.16,0-4.32l14.92-18.64a8,8,0,0,0,1.48-7.06,107.21,107.21,0,0,0-10.88-26.25,8,8,0,0,0-6-3.93l-23.72-2.64q-1.48-1.56-3-3L186,40.54a8,8,0,0,0-3.94-6,107.71,107.71,0,0,0-26.25-10.87,8,8,0,0,0-7.06,1.49L130.16,40Q128,40,125.84,40L107.2,25.11a8,8,0,0,0-7.06-1.48A107.6,107.6,0,0,0,73.89,34.51a8,8,0,0,0-3.93,6L67.32,64.27q-1.56,1.49-3,3L40.54,70a8,8,0,0,0-6,3.94,107.71,107.71,0,0,0-10.87,26.25,8,8,0,0,0,1.49,7.06L40,125.84Q40,128,40,130.16L25.11,148.8a8,8,0,0,0-1.48,7.06,107.21,107.21,0,0,0,10.88,26.25,8,8,0,0,0,6,3.93l23.72,2.64q1.49,1.56,3,3L70,215.46a8,8,0,0,0,3.94,6,107.71,107.71,0,0,0,26.25,10.87,8,8,0,0,0,7.06-1.49L125.84,216q2.16.06,4.32,0l18.64,14.92a8,8,0,0,0,7.06,1.48,107.21,107.21,0,0,0,26.25-10.88,8,8,0,0,0,3.93-6l2.64-23.72q1.56-1.48,3-3L215.46,186a8,8,0,0,0,6-3.94,107.71,107.71,0,0,0,10.87-26.25,8,8,0,0,0-1.49-7.06Zm-16.1-6.5a73.93,73.93,0,0,1,0,8.68,8,8,0,0,0,1.74,5.48l14.19,17.73a91.57,91.57,0,0,1-6.23,15L187,173.11a8,8,0,0,0-5.1,2.64,74.11,74.11,0,0,1-6.14,6.14,8,8,0,0,0-2.64,5.1l-2.51,22.58a91.32,91.32,0,0,1-15,6.23l-17.74-14.19a8,8,0,0,0-5-1.75h-.48a73.93,73.93,0,0,1-8.68,0,8,8,0,0,0-5.48,1.74L100.45,215.8a91.57,91.57,0,0,1-15-6.23L82.89,187a8,8,0,0,0-2.64-5.1,74.11,74.11,0,0,1-6.14-6.14,8,8,0,0,0-5.1-2.64L46.43,170.6a91.32,91.32,0,0,1-6.23-15l14.19-17.74a8,8,0,0,0,1.74-5.48,73.93,73.93,0,0,1,0-8.68,8,8,0,0,0-1.74-5.48L40.2,100.45a91.57,91.57,0,0,1,6.23-15L69,82.89a8,8,0,0,0,5.1-2.64,74.11,74.11,0,0,1,6.14-6.14A8,8,0,0,0,82.89,69L85.4,46.43a91.32,91.32,0,0,1,15-6.23l17.74,14.19a8,8,0,0,0,5.48,1.74,73.93,73.93,0,0,1,8.68,0,8,8,0,0,0,5.48-1.74L155.55,40.2a91.57,91.57,0,0,1,15,6.23L173.11,69a8,8,0,0,0,2.64,5.1,74.11,74.11,0,0,1,6.14,6.14,8,8,0,0,0,5.1,2.64l22.58,2.51a91.32,91.32,0,0,1,6.23,15l-14.19,17.74A8,8,0,0,0,199.87,123.66Z', - caretLeft: - 'M165.66,202.34a8,8,0,0,1-11.32,11.32l-80-80a8,8,0,0,1,0-11.32l80-80a8,8,0,0,1,11.32,11.32L91.31,128Z', - caretDown: - 'M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z', - caretRight: - 'M181.66,133.66l-80,80a8,8,0,0,1-11.32-11.32L164.69,128,90.34,53.66a8,8,0,0,1,11.32-11.32l80,80A8,8,0,0,1,181.66,133.66Z', - paperPlaneTilt: - 'M227.32,28.68a16,16,0,0,0-15.66-4.08l-.15,0L19.57,82.84a16,16,0,0,0-2.49,29.8L102,154l41.3,84.87A15.86,15.86,0,0,0,157.74,248q.69,0,1.38-.06a15.88,15.88,0,0,0,14-11.51l58.2-191.94c0-.05,0-.1,0-.15A16,16,0,0,0,227.32,28.68ZM157.83,231.85l-.05.14,0-.07-40.06-82.3,48-48a8,8,0,0,0-11.31-11.31l-48,48L24.08,98.25l-.07,0,.14,0L216,40Z', - paperclip: - 'M209.66,122.34a8,8,0,0,1,0,11.32l-82.05,82a56,56,0,0,1-79.2-79.21L147.67,35.73a40,40,0,1,1,56.61,56.55L105,193A24,24,0,1,1,71,159L154.3,74.38A8,8,0,1,1,165.7,85.6L82.39,170.31a8,8,0,1,0,11.27,11.36L192.93,81A24,24,0,1,0,159,47L59.76,147.68a40,40,0,1,0,56.53,56.62l82.06-82A8,8,0,0,1,209.66,122.34Z', - lightning: - 'M215.79,118.17a8,8,0,0,0-5-5.66L153.18,90.9l14.66-73.33a8,8,0,0,0-13.69-7l-112,120a8,8,0,0,0,3,13l57.63,21.61L88.16,238.43a8,8,0,0,0,13.69,7l112-120A8,8,0,0,0,215.79,118.17ZM109.37,214l10.47-52.38a8,8,0,0,0-5-9.06L62,132.71l84.62-90.66L136.16,94.43a8,8,0,0,0,5,9.06l52.8,19.8Z', - pushPinSimple: - 'M216,168h-9.29L185.54,48H192a8,8,0,0,0,0-16H64a8,8,0,0,0,0,16h6.46L49.29,168H40a8,8,0,0,0,0,16h80v56a8,8,0,0,0,16,0V184h80a8,8,0,0,0,0-16ZM86.71,48h82.58l21.17,120H65.54Z', - trash: - 'M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM96,40a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8v8H96Zm96,168H64V64H192ZM112,104v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Zm48,0v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Z', - x: - 'M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z', - dotsThreeVertical: - 'M140,128a12,12,0,1,1-12-12A12,12,0,0,1,140,128Zm0-56a12,12,0,1,1-12-12A12,12,0,0,1,140,72Zm0,112a12,12,0,1,1-12-12A12,12,0,0,1,140,184Z', - plus: - 'M224,128a8,8,0,0,1-8,8H136v80a8,8,0,0,1-16,0V136H40a8,8,0,0,1,0-16h80V40a8,8,0,0,1,16,0v80h80A8,8,0,0,1,224,128Z', - copy: - 'M216,32H88a8,8,0,0,0-8,8V80H40a8,8,0,0,0-8,8V216a8,8,0,0,0,8,8H168a8,8,0,0,0,8-8V176h40a8,8,0,0,0,8-8V40A8,8,0,0,0,216,32ZM160,208H48V96H160Zm48-48H176V88a8,8,0,0,0-8-8H96V48H208Z', - check: - 'M229.66,77.66l-128,128a8,8,0,0,1-11.32,0l-56-56a8,8,0,0,1,11.32-11.32L96,188.69,218.34,66.34a8,8,0,0,1,11.32,11.32Z', - crosshair: - 'M232,120h-8.34A96.14,96.14,0,0,0,136,32.34V24a8,8,0,0,0-16,0v8.34A96.14,96.14,0,0,0,32.34,120H24a8,8,0,0,0,0,16h8.34A96.14,96.14,0,0,0,120,223.66V232a8,8,0,0,0,16,0v-8.34A96.14,96.14,0,0,0,223.66,136H232a8,8,0,0,0,0-16Zm-96,87.6V200a8,8,0,0,0-16,0v7.6A80.15,80.15,0,0,1,48.4,136H56a8,8,0,0,0,0-16H48.4A80.15,80.15,0,0,1,120,48.4V56a8,8,0,0,0,16,0V48.4A80.15,80.15,0,0,1,207.6,120H200a8,8,0,0,0,0,16h7.6A80.15,80.15,0,0,1,136,207.6ZM128,88a40,40,0,1,0,40,40A40,40,0,0,0,128,88Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,152Z', - compass: - 'M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216ZM172.42,72.84l-64,32a8.05,8.05,0,0,0-3.58,3.58l-32,64A8,8,0,0,0,80,184a8.1,8.1,0,0,0,3.58-.84l64-32a8.05,8.05,0,0,0,3.58-3.58l32-64a8,8,0,0,0-10.74-10.74ZM138,138,97.89,158.11,118,118l40.15-20.07Z', - listChecks: - 'M224,128a8,8,0,0,1-8,8H128a8,8,0,0,1,0-16h88A8,8,0,0,1,224,128ZM128,72h88a8,8,0,0,0,0-16H128a8,8,0,0,0,0,16Zm88,112H128a8,8,0,0,0,0,16h88a8,8,0,0,0,0-16ZM82.34,42.34,56,68.69,45.66,58.34A8,8,0,0,0,34.34,69.66l16,16a8,8,0,0,0,11.32,0l32-32A8,8,0,0,0,82.34,42.34Zm0,64L56,132.69,45.66,122.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32,0l32-32a8,8,0,0,0-11.32-11.32Zm0,64L56,196.69,45.66,186.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32,0l32-32a8,8,0,0,0-11.32-11.32Z', - star: - 'M239.18,97.26A16.38,16.38,0,0,0,224.92,86l-59-4.76L143.14,26.15a16.36,16.36,0,0,0-30.27,0L90.11,81.23,31.08,86a16.46,16.46,0,0,0-9.37,28.86l45,38.83L53,211.75a16.38,16.38,0,0,0,24.5,17.82L128,198.49l50.53,31.08A16.4,16.4,0,0,0,203,211.75l-13.76-58.07,45-38.83A16.43,16.43,0,0,0,239.18,97.26Zm-15.34,5.47-48.7,42a8,8,0,0,0-2.56,7.91l14.88,62.8a.37.37,0,0,1-.17.48c-.18.14-.23.11-.38,0l-54.72-33.65a8,8,0,0,0-8.38,0L69.09,215.94c-.15.09-.19.12-.38,0a.37.37,0,0,1-.17-.48l14.88-62.8a8,8,0,0,0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16A8,8,0,0,0,103,91.86l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153,91.86a8,8,0,0,0,6.75,4.92l63.92,5.16c.15,0,.24,0,.33.29S224,102.63,223.84,102.73Z', - fileText: - 'M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM160,51.31,188.69,80H160ZM200,216H56V40h88V88a8,8,0,0,0,8,8h48V216Zm-32-80a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,136Zm0,32a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,168Z', - gitFork: - 'M224,64a32,32,0,1,0-40,31v17a8,8,0,0,1-8,8H80a8,8,0,0,1-8-8V95a32,32,0,1,0-16,0v17a24,24,0,0,0,24,24h40v25a32,32,0,1,0,16,0V136h40a24,24,0,0,0,24-24V95A32.06,32.06,0,0,0,224,64ZM48,64A16,16,0,1,1,64,80,16,16,0,0,1,48,64Zm96,128a16,16,0,1,1-16-16A16,16,0,0,1,144,192ZM192,80a16,16,0,1,1,16-16A16,16,0,0,1,192,80Z', - hexagon: - 'M223.68,66.15,135.68,18h0a15.88,15.88,0,0,0-15.36,0l-88,48.17a16,16,0,0,0-8.32,14v95.64a16,16,0,0,0,8.32,14l88,48.17a15.88,15.88,0,0,0,15.36,0l88-48.17a16,16,0,0,0,8.32-14V80.18A16,16,0,0,0,223.68,66.15ZM216,175.82,128,224,40,175.82V80.18L128,32h0l88,48.17Z', - }; - - function gaIcon(name, className = '') { - const d = PATHS[name]; - if (!d) return ''; - const cls = className ? ` class="${className}"` : ''; - return `

头部的副标题(来自模型 抽取) */ -.fold-turn > summary .turn-head-sum{ - font-style:italic; color:var(--muted); font-weight:normal; margin-left:4px; - min-width:0; overflow:hidden; text-overflow:ellipsis; -} - -/* —— ask_user 显眼提醒 —— */ -.ask-user-notice{ - margin:8px 0 4px; - border:1px solid var(--line); - border-left:3px solid var(--accent); - border-radius:0; - background:color-mix(in srgb, var(--accent-bg) 55%, var(--card)); - padding:10px 12px; -} -.ask-user-notice.is-active{ - border-color:color-mix(in srgb, var(--accent) 28%, var(--line)); - background:color-mix(in srgb, var(--accent-bg) 72%, var(--card)); -} -.ask-user-notice.is-answered{ - opacity:.78; - background:var(--card); - border-color:var(--line); - border-left-color:var(--muted2); -} -.ask-user-banner{ - display:flex; align-items:baseline; flex-wrap:wrap; - gap:4px 6px; - margin-bottom:8px; - line-height:1.35; -} -.ask-user-banner-text{ - font-size:13px; font-weight:600; - color:var(--txt-strong); -} -.ask-user-notice.is-active .ask-user-banner-text{ - color:var(--accent); -} -.ask-user-notice.is-answered .ask-user-banner-text{ - color:var(--muted); -} -.ask-user-banner-sep{ - color:var(--muted2); - font-size:12px; user-select:none; -} -.ask-user-banner-hint{ - font-size:12px; font-weight:400; color:var(--muted); -} -.ask-user-body{ - font-size:14px; line-height:1.55; color:var(--txt); - font-weight:500; margin:0; -} -.ask-user-body strong{ color:var(--txt-strong); } -.ask-user-body code{ - background:rgba(127,127,127,.14); - padding:1px 5px; border-radius:4px; font-size:.92em; -} -/* 覆盖 .bubble.md ul/ol/p,题干内列表与正文左缘对齐 */ -.bubble.md .ask-user-notice .ask-user-body p{ - margin:0 0 4px; -} -.bubble.md .ask-user-notice .ask-user-body p:last-child{ - margin-bottom:0; -} -.bubble.md .ask-user-notice .ask-user-body p.ask-option-line{ - padding-left:1.25em; - margin:0; - font-weight:400; -} -.bubble.md .ask-user-notice .ask-user-body span.ask-option-line{ - display:block; - padding-left:1.25em; - margin:0; - font-weight:400; - line-height:1.55; -} -.bubble.md .ask-user-notice .ask-user-body ul, -.bubble.md .ask-user-notice .ask-user-body ol{ - margin:4px 0 0; padding:0; list-style:none; -} -.bubble.md .ask-user-notice .ask-user-body ul > li, -.bubble.md .ask-user-notice .ask-user-body ol > li{ - margin:0; padding:2px 0; padding-left:0; -} -.bubble.md .ask-user-notice .ask-user-body ol{ - counter-reset:ask-ol; -} -.bubble.md .ask-user-notice .ask-user-body ol > li{ - counter-increment:ask-ol; - display:block; -} -.bubble.md .ask-user-notice .ask-user-body ol > li::before{ - content:counter(ask-ol) ". "; - font-weight:500; - color:color-mix(in srgb, var(--txt) 38%, var(--muted)); -} -.bubble.md .ask-user-notice .ask-user-body ol > li > p{ - display:inline; - margin:0; -} -.ask-user-notice.is-active .ask-user-body ol > li::before{ - color:color-mix(in srgb, var(--accent) 72%, var(--txt)); -} -.ask-candidates{ - display:flex; flex-direction:column; gap:0; -} -.ask-candidates li{ - display:block; - font-size:14px; line-height:1.55; color:var(--txt); -} -.ask-candidate-key{ - font-weight:500; - color:color-mix(in srgb, var(--txt) 38%, var(--muted)); -} -.ask-user-notice.is-active .ask-candidate-key{ - color:color-mix(in srgb, var(--accent) 72%, var(--txt)); -} -.ask-user-notice.is-answered .ask-candidate-key{ - color:var(--muted2); -} -.ask-candidate-label{ - white-space:pre-wrap; word-break:break-word; -} - -/* —— 消息计时 badge —— */ -.task-elapsed{ - display:block; font-size:11px; color:var(--muted); - line-height:1; min-height:18px; - padding:0 6px; margin-bottom:2px; - border-radius:var(--radius-sm); -} - -/* ---- Conductor(仅保留与主聊天不同的部分,布局见 .page--chat-ui) ---- */ -#collab-welcome[hidden], .collab-msgs .msgs[hidden], -.collab-rail[hidden], .collab-prog-stats[hidden], -.collab-rail-run[hidden], .collab-rail-done[hidden], .collab-rail-issue[hidden]{ display:none !important; } -/* 顶栏中央:状态栏 + 离线时的刷新图标按钮(取代原本的 banner+重试) */ -.page > .page-top .page-top-center{ - grid-column:2; display:flex; align-items:center; justify-content:center; gap:6px; - min-width:0; -} -/* 收尾按钮:跟 .preset-restore 同款 — 白底 + 中性边框, hover 转 accent 色 */ -.collab-retry-btn{ - width:24px; height:24px; - border:1px solid var(--line); background:var(--card); border-radius:4px; - opacity:1; color:var(--txt-2); -} -.collab-retry-btn:hover{ background:var(--hover-2); color:var(--accent); border-color:var(--accent); opacity:1; } -.collab-retry-btn svg{ width:14px; height:14px; } -.collab-retry-btn[hidden]{ display:none !important; } -.page.page--chat-ui[data-page="collab"] .msg-area.collab-msgs{ overflow:hidden; padding:0; } -.page.page--chat-ui[data-page="collab"] .collab-scroll{ - flex:1 1 0; min-height:0; overflow:auto; - display:flex; flex-direction:column; align-items:center; justify-content:center; gap:14px; - padding:16px; -} -.page.page--chat-ui[data-page="collab"] .collab-msgs.has-msgs .collab-scroll{ - align-items:stretch; justify-content:flex-start; -} -.page.page--chat-ui[data-page="collab"] .collab-overlay{ - position:absolute; inset:0; z-index:10; pointer-events:none; -} -.page.page--chat-ui[data-page="collab"] .collab-overlay > .collab-rail, -.page.page--chat-ui[data-page="collab"] .collab-overlay > .collab-prog-panel{ pointer-events:auto; } -.page.page--chat-ui[data-page="collab"].dragover .collab-msgs::after{ - content:attr(data-drop-hint); position:absolute; inset:8px; z-index:20; pointer-events:none; - display:flex; align-items:center; justify-content:center; border-radius:var(--radius); - border:2px dashed var(--accent); background:color-mix(in srgb, var(--accent) 8%, var(--panel)); - font-size:13px; color:var(--accent); font-weight:500; -} -.collab-rail{ - position:absolute; left:0; top:50%; z-index:14; - transform:translateY(-50%); - transition:left .22s ease-out; -} -.page.page--chat-ui[data-page="collab"].collab-prog-open .collab-rail{ left:232px; } -.collab-rail-handle{ - display:flex; flex-direction:column; align-items:center; justify-content:center; gap:5px; - width:22px; padding:10px 3px; cursor:pointer; - border:1px solid var(--line); border-left:none; - border-radius:0 var(--radius-sm) var(--radius-sm) 0; - background:var(--card); color:var(--muted); - box-shadow:2px 0 8px rgba(0,0,0,.06); - transition:border-color .2s, color .2s, box-shadow .2s; -} -.collab-rail-handle:hover{ border-color:var(--line-strong); color:var(--txt-2); box-shadow:2px 0 12px rgba(0,0,0,.1); } -.collab-rail-grip{ - width:3px; height:18px; border-radius:2px; - background:linear-gradient(180deg, transparent, var(--line-strong) 20%, var(--line-strong) 80%, transparent); - opacity:.55; -} -.collab-rail-chev{ width:12px; height:12px; flex:0 0 auto; transition:transform .22s ease-out; } -.page.page--chat-ui[data-page="collab"].collab-prog-open .collab-rail-chev{ transform:rotate(180deg); } -.collab-rail-badge{ - display:flex; flex-direction:column; align-items:center; gap:2px; - font-size:10px; font-weight:500; line-height:1; - font-family:var(--font-num); font-variant-numeric:var(--font-num-variant); -} -.collab-rail-badge.pulse{ animation:collab-rail-pulse .55s ease-out; } -@keyframes collab-rail-pulse{ - 0%{ transform:scale(1); } - 35%{ transform:scale(1.12); } - 100%{ transform:scale(1); } -} -/* 把手运行数:转圈与呼吸上下分开,转圈内不叠绿点 */ -.collab-rail-spin{ - display:block; - width:12px; height:12px; - margin:0 auto; - border-radius:50%; - background:transparent; - border:2px solid color-mix(in srgb, var(--ok) 22%, transparent); - border-top-color:var(--ok); - animation:collab-rail-spin .8s linear infinite; -} -@keyframes collab-rail-spin{ to{ transform:rotate(360deg); } } -.collab-rail-dot{ width:6px; height:6px; border-radius:50%; background:var(--ok); flex:0 0 auto; } -.collab-rail-run .n{ color:var(--ok); } -.collab-rail-done .n{ color:var(--muted); } -.collab-rail-done .collab-rail-dot{ background:var(--muted2); } -.collab-rail-issue .n{ color:var(--danger); } -.collab-rail-dot--danger{ background:var(--danger-dot); } -.collab-prog-panel{ - position:absolute; top:0; left:0; bottom:0; width:232px; z-index:12; - display:flex; flex-direction:column; overflow:hidden; - background:var(--panel); border-right:1px solid var(--line); - box-shadow:4px 0 16px rgba(0,0,0,.06); - transform:translateX(-100%); transition:transform .22s ease-out; - pointer-events:none; -} -.page.page--chat-ui[data-page="collab"].collab-prog-open .collab-prog-panel{ - transform:translateX(0); pointer-events:auto; -} -.collab-prog-panel-head{ - flex:0 0 auto; display:flex; align-items:center; justify-content:space-between; gap:6px; - padding:8px 8px 4px 12px; - font-size:13px; font-weight:600; color:var(--txt-strong); -} -.collab-prog-panel-head .ic-btn{ width:26px; height:26px; } -.collab-prog-panel-head .ic-btn svg{ width:14px; height:14px; } -.collab-prog-stats{ - flex:0 0 auto; display:flex; flex-wrap:wrap; gap:5px; - padding:2px 10px 5px; -} -.collab-stat{ - display:inline-flex; align-items:center; gap:4px; - font-size:10px; font-weight:500; padding:2px 7px; border-radius:4px; - line-height:1.35; -} -.collab-stat--running{ color:var(--ok); background:color-mix(in srgb, var(--ok) 10%, var(--panel)); } -.collab-stat--done{ color:var(--muted); background:color-mix(in srgb, var(--muted) 10%, var(--panel)); } -.collab-prog-stats .ga-status-breathe--sm{ --breathe-box:8px; } -.collab-prog-stats .collab-rail-dot{ width:5px; height:5px; } -.collab-stat--done .collab-rail-dot{ background:var(--muted2); } -.collab-stat .n{ font-family:var(--font-num); font-variant-numeric:var(--font-num-variant); font-weight:500; font-size:10px; } -.collab-progress-empty{ - flex:0 0 auto; font-size:12px; color:var(--muted); line-height:1.35; - padding:4px 10px 2px; -} -#collab-workers{ - flex:1 1 0; min-height:0; overflow-y:auto; padding:6px 10px 12px; -} -.collab-card{ - position:relative; - border:1px solid var(--line); border-radius:6px; background:var(--card); - padding:8px 10px 8px 12px; margin-bottom:6px; border-left:3px solid var(--collab-st, var(--line)); - box-shadow:0 1px 4px rgba(0,0,0,.04); - transition:border-color .3s, box-shadow .3s, transform .2s; -} -.collab-card.pulse{ animation:collab-rail-pulse .55s ease-out; } -.collab-card:hover{ transform:translateY(-1px); box-shadow:0 4px 14px rgba(0,0,0,.07); } -.collab-card--running{ --collab-st:var(--ok); } -.collab-card--reported{ --collab-st:var(--muted); } -.collab-card--paused{ --collab-st:var(--amber); } -.collab-card--failed{ --collab-st:var(--danger); } -.collab-card--terminated{ --collab-st:var(--line-strong); } -.collab-card-st{ display:flex; align-items:center; gap:5px; font-size:11px; color:var(--muted); margin-bottom:4px; flex-wrap:wrap; } -.collab-dot{ width:7px; height:7px; border-radius:50%; background:var(--collab-st); flex:0 0 auto; } -.collab-st-ic{ display:inline-flex; align-items:center; justify-content:center; width:14px; height:14px; font-size:10px; font-weight:500; flex:0 0 auto; } -.collab-st-ic--ok{ color:var(--ok); } -.collab-st-ic--done{ color:var(--muted); } -.collab-st-ic--warn{ color:var(--danger); } -.collab-st-ic--pause,.collab-st-ic--off{ color:var(--muted); } -.collab-card-time{ margin-left:auto; font-size:10px; color:var(--muted); opacity:.85; } -.collab-card-title{ font-size:13px; font-weight:500; color:var(--txt-strong); margin-bottom:3px; line-height:1.35; } -.collab-card-sum{ font-size:11px; color:var(--muted); line-height:1.35; cursor:pointer; } -.collab-drawer-wrap{ position:fixed; inset:0; z-index:550; display:flex; } -.collab-drawer-backdrop{ position:absolute; inset:0; background:var(--overlay); } -.collab-drawer{ - position:absolute; right:0; top:0; bottom:0; width:min(420px,85vw); - background:var(--panel); border-left:1px solid var(--line); - box-shadow:var(--shadow-modal); display:flex; flex-direction:column; - animation:collabDrawerIn .2s ease-out; -} -@keyframes collabDrawerIn{ from{ transform:translateX(100%); } to{ transform:translateX(0); } } -.collab-drawer-head{ - display:flex; align-items:center; justify-content:space-between; - padding:14px 16px; border-bottom:1px solid var(--line-soft); -} -.collab-drawer-title{ font-size:14px; font-weight:600; color:var(--txt-strong); } -.collab-drawer-body{ - flex:1; overflow-y:auto; padding:16px; font-size:13px; line-height:1.55; color:var(--txt); -} -.collab-drawer-body p,.collab-drawer-body ul,.collab-drawer-body ol,.collab-drawer-body pre{ margin:0 0 .5em; } -.collab-drawer-body code{ font-size:12px; background:var(--field-bg); padding:1px 4px; border-radius:4px; } -.collab-drawer-body pre code{ display:block; padding:8px; border-radius:6px; white-space:pre-wrap; } -.collab-msg-enter{ animation:collab-msg-in .35s ease-out both; } -.bubble.sys .collab-wait-dots{ - display:inline-flex; align-items:center; justify-content:center; gap:5px; - vertical-align:middle; padding:2px 0; -} -.collab-wait-dots i{ - display:block; width:5px; height:5px; border-radius:50%; background:var(--muted); - animation:collab-wait-dot 1.2s ease-in-out infinite; font-style:normal; -} -.collab-wait-dots i:nth-child(1){ animation-delay:0s; } -.collab-wait-dots i:nth-child(2){ animation-delay:.2s; } -.collab-wait-dots i:nth-child(3){ animation-delay:.4s; } -@keyframes collab-wait-dot{ - 0%,80%,100%{ opacity:.35; transform:translateY(0); } - 40%{ opacity:1; transform:translateY(-3px); } -} -@keyframes collab-msg-in{ - from{ opacity:0; transform:translateY(8px); } - to{ opacity:1; transform:translateY(0); } -} -.collab-guide{ width:100%; max-width:var(--chat-col-w); margin:0 auto; } -.collab-guide .feature-head{ text-align:center; margin-bottom:22px; } -.collab-guide .page-title{ font-size:20px; line-height:1.35; margin-bottom:2px; } -.collab-guide .page-sub{ font-size:13px; line-height:1.55; margin-top:10px; margin-bottom:0; } -.collab-guide-steps{ - display:grid; grid-template-columns:repeat(4, minmax(0, 1fr)); gap:48px 56px; - margin:0 0 8px; align-items:start; -} -.collab-guide-step{ - display:flex; flex-direction:column; align-items:center; gap:22px; - text-align:center; min-height:100%; -} -.collab-guide-ic{ - flex:0 0 auto; width:52px; height:52px; border-radius:14px; - display:flex; align-items:center; justify-content:center; - background:color-mix(in srgb, var(--accent) 10%, var(--panel)); - color:var(--accent); - border:1px solid color-mix(in srgb, var(--accent) 16%, var(--line)); -} -.collab-guide-ic svg{ width:24px; height:24px; } -.collab-guide-txt{ display:flex; flex-direction:column; gap:12px; width:100%; } -.collab-guide-label{ font-size:14px; font-weight:500; color:var(--txt-strong); line-height:1.35; } -.collab-guide-desc{ font-size:12px; font-weight:400; font-style:italic; color:var(--muted); line-height:1.55; } -.collab-nav-badge{ - margin-left:auto; min-width:18px; height:18px; padding:0 5px; border-radius:999px; - background:var(--ok); color:#fff; font-size:10px; font-weight:500; line-height:1; text-align:center; -} -.page.page--chat-ui[data-page="collab"] > .chat-shell > .chat-panel > .collab-quick{ - flex:0 0 auto; - display:flex; flex-wrap:wrap; gap:8px; -} -@media (max-width:900px){ - .collab-guide-steps{ grid-template-columns:repeat(2, minmax(0, 1fr)); gap:40px 32px; } - .collab-guide-step{ gap:20px; } - .page.page--chat-ui[data-page="collab"].collab-prog-open .collab-rail{ left:min(232px, 72vw); } - .collab-prog-panel{ width:min(232px, 72vw); } -} diff --git a/frontends/desktop/public/vendor/marked.min.js b/frontends/desktop/public/vendor/marked.min.js deleted file mode 100644 index 4052d1b49..000000000 --- a/frontends/desktop/public/vendor/marked.min.js +++ /dev/null @@ -1,6 +0,0 @@ -/** - * marked v15.0.7 - a markdown parser - * Copyright (c) 2011-2025, Christopher Jeffrey. (MIT Licensed) - * https://github.com/markedjs/marked - */ -!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).marked={})}(this,(function(e){"use strict";function t(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}function n(t){e.defaults=t}e.defaults={async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null};const s={exec:()=>null};function r(e,t=""){let n="string"==typeof e?e:e.source;const s={replace:(e,t)=>{let r="string"==typeof t?t:t.source;return r=r.replace(i.caret,"$1"),n=n.replace(e,r),s},getRegex:()=>new RegExp(n,t)};return s}const i={codeRemoveIndent:/^(?: {1,4}| {0,3}\t)/gm,outputLinkReplace:/\\([\[\]])/g,indentCodeCompensation:/^(\s+)(?:```)/,beginningSpace:/^\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\n/g,tabCharGlobal:/\t/g,multipleSpaceGlobal:/\s+/g,blankLine:/^[ \t]*$/,doubleBlankLine:/\n[ \t]*\n[ \t]*$/,blockquoteStart:/^ {0,3}>/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] /,listReplaceTask:/^\[[ xX]\] +/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:e=>new RegExp(`^( {0,3}${e})((?:[\t ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`),hrRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}#`),htmlBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}<(?:[a-z].*>|!--)`,"i")},l=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,o=/(?:[*+-]|\d{1,9}[.)])/,a=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,c=r(a).replace(/bull/g,o).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),h=r(a).replace(/bull/g,o).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),p=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,u=/(?!\s*\])(?:\\.|[^\[\]\\])+/,g=r(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",u).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),k=r(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,o).getRegex(),d="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",f=/|$))/,x=r("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$))","i").replace("comment",f).replace("tag",d).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),b=r(p).replace("hr",l).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",d).getRegex(),w={blockquote:r(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",b).getRegex(),code:/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,def:g,fences:/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,heading:/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,hr:l,html:x,lheading:c,list:k,newline:/^(?:[ \t]*(?:\n|$))+/,paragraph:b,table:s,text:/^[^\n]+/},m=r("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",l).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3}\t)[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",d).getRegex(),y={...w,lheading:h,table:m,paragraph:r(p).replace("hr",l).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",m).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",d).getRegex()},$={...w,html:r("^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))").replace("comment",f).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:s,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:r(p).replace("hr",l).replace("heading"," *#{1,6} *[^\n]").replace("lheading",c).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},R=/^( {2,}|\\)\n(?!\s*$)/,S=/[\p{P}\p{S}]/u,T=/[\s\p{P}\p{S}]/u,z=/[^\s\p{P}\p{S}]/u,A=r(/^((?![*_])punctSpace)/,"u").replace(/punctSpace/g,T).getRegex(),_=/(?!~)[\p{P}\p{S}]/u,P=/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,I=r(P,"u").replace(/punct/g,S).getRegex(),L=r(P,"u").replace(/punct/g,_).getRegex(),B="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",C=r(B,"gu").replace(/notPunctSpace/g,z).replace(/punctSpace/g,T).replace(/punct/g,S).getRegex(),q=r(B,"gu").replace(/notPunctSpace/g,/(?:[^\s\p{P}\p{S}]|~)/u).replace(/punctSpace/g,/(?!~)[\s\p{P}\p{S}]/u).replace(/punct/g,_).getRegex(),E=r("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,z).replace(/punctSpace/g,T).replace(/punct/g,S).getRegex(),Z=r(/\\(punct)/,"gu").replace(/punct/g,S).getRegex(),v=r(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),D=r(f).replace("(?:--\x3e|$)","--\x3e").getRegex(),M=r("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",D).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),O=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,Q=r(/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/).replace("label",O).replace("href",/<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),j=r(/^!?\[(label)\]\[(ref)\]/).replace("label",O).replace("ref",u).getRegex(),N=r(/^!?\[(ref)\](?:\[\])?/).replace("ref",u).getRegex(),G={_backpedal:s,anyPunctuation:Z,autolink:v,blockSkip:/\[[^[\]]*?\]\((?:\\.|[^\\\(\)]|\((?:\\.|[^\\\(\)])*\))*\)|`[^`]*?`|<[^<>]*?>/g,br:R,code:/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,del:s,emStrongLDelim:I,emStrongRDelimAst:C,emStrongRDelimUnd:E,escape:/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,link:Q,nolink:N,punctuation:A,reflink:j,reflinkSearch:r("reflink|nolink(?!\\()","g").replace("reflink",j).replace("nolink",N).getRegex(),tag:M,text:/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},V=e=>K[e];function W(e,t){if(t){if(i.escapeTest.test(e))return e.replace(i.escapeReplace,V)}else if(i.escapeTestNoEncode.test(e))return e.replace(i.escapeReplaceNoEncode,V);return e}function Y(e){try{e=encodeURI(e).replace(i.percentDecode,"%")}catch{return null}return e}function ee(e,t){const n=e.replace(i.findPipe,((e,t,n)=>{let s=!1,r=t;for(;--r>=0&&"\\"===n[r];)s=!s;return s?"|":" |"})).split(i.splitPipe);let s=0;if(n[0].trim()||n.shift(),n.length>0&&!n.at(-1)?.trim()&&n.pop(),t)if(n.length>t)n.splice(t);else for(;n.length0)return{type:"space",raw:t[0]}}code(e){const t=this.rules.block.code.exec(e);if(t){const e=t[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?e:te(e,"\n")}}}fences(e){const t=this.rules.block.fences.exec(e);if(t){const e=t[0],n=function(e,t,n){const s=e.match(n.other.indentCodeCompensation);if(null===s)return t;const r=s[1];return t.split("\n").map((e=>{const t=e.match(n.other.beginningSpace);if(null===t)return e;const[s]=t;return s.length>=r.length?e.slice(r.length):e})).join("\n")}(e,t[3]||"",this.rules);return{type:"code",raw:e,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:n}}}heading(e){const t=this.rules.block.heading.exec(e);if(t){let e=t[2].trim();if(this.rules.other.endingHash.test(e)){const t=te(e,"#");this.options.pedantic?e=t.trim():t&&!this.rules.other.endingSpaceChar.test(t)||(e=t.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:e,tokens:this.lexer.inline(e)}}}hr(e){const t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:te(t[0],"\n")}}blockquote(e){const t=this.rules.block.blockquote.exec(e);if(t){let e=te(t[0],"\n").split("\n"),n="",s="";const r=[];for(;e.length>0;){let t=!1;const i=[];let l;for(l=0;l1,r={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");const i=this.rules.other.listItemRegex(n);let l=!1;for(;e;){let n=!1,s="",o="";if(!(t=i.exec(e)))break;if(this.rules.block.hr.test(e))break;s=t[0],e=e.substring(s.length);let a=t[2].split("\n",1)[0].replace(this.rules.other.listReplaceTabs,(e=>" ".repeat(3*e.length))),c=e.split("\n",1)[0],h=!a.trim(),p=0;if(this.options.pedantic?(p=2,o=a.trimStart()):h?p=t[1].length+1:(p=t[2].search(this.rules.other.nonSpaceChar),p=p>4?1:p,o=a.slice(p),p+=t[1].length),h&&this.rules.other.blankLine.test(c)&&(s+=c+"\n",e=e.substring(c.length+1),n=!0),!n){const t=this.rules.other.nextBulletRegex(p),n=this.rules.other.hrRegex(p),r=this.rules.other.fencesBeginRegex(p),i=this.rules.other.headingBeginRegex(p),l=this.rules.other.htmlBeginRegex(p);for(;e;){const u=e.split("\n",1)[0];let g;if(c=u,this.options.pedantic?(c=c.replace(this.rules.other.listReplaceNesting," "),g=c):g=c.replace(this.rules.other.tabCharGlobal," "),r.test(c))break;if(i.test(c))break;if(l.test(c))break;if(t.test(c))break;if(n.test(c))break;if(g.search(this.rules.other.nonSpaceChar)>=p||!c.trim())o+="\n"+g.slice(p);else{if(h)break;if(a.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4)break;if(r.test(a))break;if(i.test(a))break;if(n.test(a))break;o+="\n"+c}h||c.trim()||(h=!0),s+=u+"\n",e=e.substring(u.length+1),a=g.slice(p)}}r.loose||(l?r.loose=!0:this.rules.other.doubleBlankLine.test(s)&&(l=!0));let u,g=null;this.options.gfm&&(g=this.rules.other.listIsTask.exec(o),g&&(u="[ ] "!==g[0],o=o.replace(this.rules.other.listReplaceTask,""))),r.items.push({type:"list_item",raw:s,task:!!g,checked:u,loose:!1,text:o,tokens:[]}),r.raw+=s}const o=r.items.at(-1);if(!o)return;o.raw=o.raw.trimEnd(),o.text=o.text.trimEnd(),r.raw=r.raw.trimEnd();for(let e=0;e"space"===e.type)),n=t.length>0&&t.some((e=>this.rules.other.anyLine.test(e.raw)));r.loose=n}if(r.loose)for(let e=0;e({text:e,tokens:this.lexer.inline(e),header:!1,align:i.align[t]}))));return i}}lheading(e){const t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:"="===t[2].charAt(0)?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){const t=this.rules.block.paragraph.exec(e);if(t){const e="\n"===t[1].charAt(t[1].length-1)?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:e,tokens:this.lexer.inline(e)}}}text(e){const t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){const t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:t[1]}}tag(e){const t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&this.rules.other.startATag.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){const t=this.rules.inline.link.exec(e);if(t){const e=t[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(e)){if(!this.rules.other.endAngleBracket.test(e))return;const t=te(e.slice(0,-1),"\\");if((e.length-t.length)%2==0)return}else{const e=function(e,t){if(-1===e.indexOf(t[1]))return-1;let n=0;for(let s=0;s-1){const n=(0===t[0].indexOf("!")?5:4)+t[1].length+e;t[2]=t[2].substring(0,e),t[0]=t[0].substring(0,n).trim(),t[3]=""}}let n=t[2],s="";if(this.options.pedantic){const e=this.rules.other.pedanticHrefTitle.exec(n);e&&(n=e[1],s=e[3])}else s=t[3]?t[3].slice(1,-1):"";return n=n.trim(),this.rules.other.startAngleBracket.test(n)&&(n=this.options.pedantic&&!this.rules.other.endAngleBracket.test(e)?n.slice(1):n.slice(1,-1)),ne(t,{href:n?n.replace(this.rules.inline.anyPunctuation,"$1"):n,title:s?s.replace(this.rules.inline.anyPunctuation,"$1"):s},t[0],this.lexer,this.rules)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){const e=t[(n[2]||n[1]).replace(this.rules.other.multipleSpaceGlobal," ").toLowerCase()];if(!e){const e=n[0].charAt(0);return{type:"text",raw:e,text:e}}return ne(n,e,n[0],this.lexer,this.rules)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrongLDelim.exec(e);if(!s)return;if(s[3]&&n.match(this.rules.other.unicodeAlphaNumeric))return;if(!(s[1]||s[2]||"")||!n||this.rules.inline.punctuation.exec(n)){const n=[...s[0]].length-1;let r,i,l=n,o=0;const a="*"===s[0][0]?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(a.lastIndex=0,t=t.slice(-1*e.length+n);null!=(s=a.exec(t));){if(r=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!r)continue;if(i=[...r].length,s[3]||s[4]){l+=i;continue}if((s[5]||s[6])&&n%3&&!((n+i)%3)){o+=i;continue}if(l-=i,l>0)continue;i=Math.min(i,i+l+o);const t=[...s[0]][0].length,a=e.slice(0,n+s.index+t+i);if(Math.min(n,i)%2){const e=a.slice(1,-1);return{type:"em",raw:a,text:e,tokens:this.lexer.inlineTokens(e)}}const c=a.slice(2,-2);return{type:"strong",raw:a,text:c,tokens:this.lexer.inlineTokens(c)}}}}codespan(e){const t=this.rules.inline.code.exec(e);if(t){let e=t[2].replace(this.rules.other.newLineCharGlobal," ");const n=this.rules.other.nonSpaceChar.test(e),s=this.rules.other.startingSpaceChar.test(e)&&this.rules.other.endingSpaceChar.test(e);return n&&s&&(e=e.substring(1,e.length-1)),{type:"codespan",raw:t[0],text:e}}}br(e){const t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){const t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){const t=this.rules.inline.autolink.exec(e);if(t){let e,n;return"@"===t[2]?(e=t[1],n="mailto:"+e):(e=t[1],n=e),{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let e,n;if("@"===t[2])e=t[0],n="mailto:"+e;else{let s;do{s=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??""}while(s!==t[0]);e=t[0],n="www."===t[1]?"http://"+t[0]:t[0]}return{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}inlineText(e){const t=this.rules.inline.text.exec(e);if(t){const e=this.lexer.state.inRawBlock;return{type:"text",raw:t[0],text:t[0],escaped:e}}}}class re{tokens;options;state;tokenizer;inlineQueue;constructor(t){this.tokens=[],this.tokens.links=Object.create(null),this.options=t||e.defaults,this.options.tokenizer=this.options.tokenizer||new se,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};const n={other:i,block:U.normal,inline:J.normal};this.options.pedantic?(n.block=U.pedantic,n.inline=J.pedantic):this.options.gfm&&(n.block=U.gfm,this.options.breaks?n.inline=J.breaks:n.inline=J.gfm),this.tokenizer.rules=n}static get rules(){return{block:U,inline:J}}static lex(e,t){return new re(t).lex(e)}static lexInline(e,t){return new re(t).inlineTokens(e)}lex(e){e=e.replace(i.carriageReturn,"\n"),this.blockTokens(e,this.tokens);for(let e=0;e!!(s=n.call({lexer:this},e,t))&&(e=e.substring(s.raw.length),t.push(s),!0))))continue;if(s=this.tokenizer.space(e)){e=e.substring(s.raw.length);const n=t.at(-1);1===s.raw.length&&void 0!==n?n.raw+="\n":t.push(s);continue}if(s=this.tokenizer.code(e)){e=e.substring(s.raw.length);const n=t.at(-1);"paragraph"===n?.type||"text"===n?.type?(n.raw+="\n"+s.raw,n.text+="\n"+s.text,this.inlineQueue.at(-1).src=n.text):t.push(s);continue}if(s=this.tokenizer.fences(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.heading(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.hr(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.blockquote(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.list(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.html(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.def(e)){e=e.substring(s.raw.length);const n=t.at(-1);"paragraph"===n?.type||"text"===n?.type?(n.raw+="\n"+s.raw,n.text+="\n"+s.raw,this.inlineQueue.at(-1).src=n.text):this.tokens.links[s.tag]||(this.tokens.links[s.tag]={href:s.href,title:s.title});continue}if(s=this.tokenizer.table(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.lheading(e)){e=e.substring(s.raw.length),t.push(s);continue}let r=e;if(this.options.extensions?.startBlock){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startBlock.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(r=e.substring(0,t+1))}if(this.state.top&&(s=this.tokenizer.paragraph(r))){const i=t.at(-1);n&&"paragraph"===i?.type?(i.raw+="\n"+s.raw,i.text+="\n"+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=i.text):t.push(s),n=r.length!==e.length,e=e.substring(s.raw.length)}else if(s=this.tokenizer.text(e)){e=e.substring(s.raw.length);const n=t.at(-1);"text"===n?.type?(n.raw+="\n"+s.raw,n.text+="\n"+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=n.text):t.push(s)}else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let n=e,s=null;if(this.tokens.links){const e=Object.keys(this.tokens.links);if(e.length>0)for(;null!=(s=this.tokenizer.rules.inline.reflinkSearch.exec(n));)e.includes(s[0].slice(s[0].lastIndexOf("[")+1,-1))&&(n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;null!=(s=this.tokenizer.rules.inline.blockSkip.exec(n));)n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);for(;null!=(s=this.tokenizer.rules.inline.anyPunctuation.exec(n));)n=n.slice(0,s.index)+"++"+n.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);let r=!1,i="";for(;e;){let s;if(r||(i=""),r=!1,this.options.extensions?.inline?.some((n=>!!(s=n.call({lexer:this},e,t))&&(e=e.substring(s.raw.length),t.push(s),!0))))continue;if(s=this.tokenizer.escape(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.tag(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.link(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(s.raw.length);const n=t.at(-1);"text"===s.type&&"text"===n?.type?(n.raw+=s.raw,n.text+=s.text):t.push(s);continue}if(s=this.tokenizer.emStrong(e,n,i)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.codespan(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.br(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.del(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.autolink(e)){e=e.substring(s.raw.length),t.push(s);continue}if(!this.state.inLink&&(s=this.tokenizer.url(e))){e=e.substring(s.raw.length),t.push(s);continue}let l=e;if(this.options.extensions?.startInline){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startInline.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(l=e.substring(0,t+1))}if(s=this.tokenizer.inlineText(l)){e=e.substring(s.raw.length),"_"!==s.raw.slice(-1)&&(i=s.raw.slice(-1)),r=!0;const n=t.at(-1);"text"===n?.type?(n.raw+=s.raw,n.text+=s.text):t.push(s)}else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}return t}}class ie{options;parser;constructor(t){this.options=t||e.defaults}space(e){return""}code({text:e,lang:t,escaped:n}){const s=(t||"").match(i.notSpaceStart)?.[0],r=e.replace(i.endingNewline,"")+"\n";return s?'
'+(n?r:W(r,!0))+"
\n":"
"+(n?r:W(r,!0))+"
\n"}blockquote({tokens:e}){return`
\n${this.parser.parse(e)}
\n`}html({text:e}){return e}heading({tokens:e,depth:t}){return`${this.parser.parseInline(e)}\n`}hr(e){return"
\n"}list(e){const t=e.ordered,n=e.start;let s="";for(let t=0;t\n"+s+"\n"}listitem(e){let t="";if(e.task){const n=this.checkbox({checked:!!e.checked});e.loose?"paragraph"===e.tokens[0]?.type?(e.tokens[0].text=n+" "+e.tokens[0].text,e.tokens[0].tokens&&e.tokens[0].tokens.length>0&&"text"===e.tokens[0].tokens[0].type&&(e.tokens[0].tokens[0].text=n+" "+W(e.tokens[0].tokens[0].text),e.tokens[0].tokens[0].escaped=!0)):e.tokens.unshift({type:"text",raw:n+" ",text:n+" ",escaped:!0}):t+=n+" "}return t+=this.parser.parse(e.tokens,!!e.loose),`
  • ${t}
  • \n`}checkbox({checked:e}){return"'}paragraph({tokens:e}){return`

    ${this.parser.parseInline(e)}

    \n`}table(e){let t="",n="";for(let t=0;t${s}`),"\n\n"+t+"\n"+s+"
    \n"}tablerow({text:e}){return`\n${e}\n`}tablecell(e){const t=this.parser.parseInline(e.tokens),n=e.header?"th":"td";return(e.align?`<${n} align="${e.align}">`:`<${n}>`)+t+`\n`}strong({tokens:e}){return`${this.parser.parseInline(e)}`}em({tokens:e}){return`${this.parser.parseInline(e)}`}codespan({text:e}){return`${W(e,!0)}`}br(e){return"
    "}del({tokens:e}){return`${this.parser.parseInline(e)}`}link({href:e,title:t,tokens:n}){const s=this.parser.parseInline(n),r=Y(e);if(null===r)return s;let i='
    ",i}image({href:e,title:t,text:n}){const s=Y(e);if(null===s)return W(n);let r=`${n}{const r=e[s].flat(1/0);n=n.concat(this.walkTokens(r,t))})):e.tokens&&(n=n.concat(this.walkTokens(e.tokens,t)))}}return n}use(...e){const t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach((e=>{const n={...e};if(n.async=this.defaults.async||n.async||!1,e.extensions&&(e.extensions.forEach((e=>{if(!e.name)throw new Error("extension name required");if("renderer"in e){const n=t.renderers[e.name];t.renderers[e.name]=n?function(...t){let s=e.renderer.apply(this,t);return!1===s&&(s=n.apply(this,t)),s}:e.renderer}if("tokenizer"in e){if(!e.level||"block"!==e.level&&"inline"!==e.level)throw new Error("extension level must be 'block' or 'inline'");const n=t[e.level];n?n.unshift(e.tokenizer):t[e.level]=[e.tokenizer],e.start&&("block"===e.level?t.startBlock?t.startBlock.push(e.start):t.startBlock=[e.start]:"inline"===e.level&&(t.startInline?t.startInline.push(e.start):t.startInline=[e.start]))}"childTokens"in e&&e.childTokens&&(t.childTokens[e.name]=e.childTokens)})),n.extensions=t),e.renderer){const t=this.defaults.renderer||new ie(this.defaults);for(const n in e.renderer){if(!(n in t))throw new Error(`renderer '${n}' does not exist`);if(["options","parser"].includes(n))continue;const s=n,r=e.renderer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n||""}}n.renderer=t}if(e.tokenizer){const t=this.defaults.tokenizer||new se(this.defaults);for(const n in e.tokenizer){if(!(n in t))throw new Error(`tokenizer '${n}' does not exist`);if(["options","rules","lexer"].includes(n))continue;const s=n,r=e.tokenizer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.tokenizer=t}if(e.hooks){const t=this.defaults.hooks||new ae;for(const n in e.hooks){if(!(n in t))throw new Error(`hook '${n}' does not exist`);if(["options","block"].includes(n))continue;const s=n,r=e.hooks[s],i=t[s];ae.passThroughHooks.has(n)?t[s]=e=>{if(this.defaults.async)return Promise.resolve(r.call(t,e)).then((e=>i.call(t,e)));const n=r.call(t,e);return i.call(t,n)}:t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.hooks=t}if(e.walkTokens){const t=this.defaults.walkTokens,s=e.walkTokens;n.walkTokens=function(e){let n=[];return n.push(s.call(this,e)),t&&(n=n.concat(t.call(this,e))),n}}this.defaults={...this.defaults,...n}})),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return re.lex(e,t??this.defaults)}parser(e,t){return oe.parse(e,t??this.defaults)}parseMarkdown(e){return(t,n)=>{const s={...n},r={...this.defaults,...s},i=this.onError(!!r.silent,!!r.async);if(!0===this.defaults.async&&!1===s.async)return i(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(null==t)return i(new Error("marked(): input parameter is undefined or null"));if("string"!=typeof t)return i(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(t)+", string expected"));r.hooks&&(r.hooks.options=r,r.hooks.block=e);const l=r.hooks?r.hooks.provideLexer():e?re.lex:re.lexInline,o=r.hooks?r.hooks.provideParser():e?oe.parse:oe.parseInline;if(r.async)return Promise.resolve(r.hooks?r.hooks.preprocess(t):t).then((e=>l(e,r))).then((e=>r.hooks?r.hooks.processAllTokens(e):e)).then((e=>r.walkTokens?Promise.all(this.walkTokens(e,r.walkTokens)).then((()=>e)):e)).then((e=>o(e,r))).then((e=>r.hooks?r.hooks.postprocess(e):e)).catch(i);try{r.hooks&&(t=r.hooks.preprocess(t));let e=l(t,r);r.hooks&&(e=r.hooks.processAllTokens(e)),r.walkTokens&&this.walkTokens(e,r.walkTokens);let n=o(e,r);return r.hooks&&(n=r.hooks.postprocess(n)),n}catch(e){return i(e)}}}onError(e,t){return n=>{if(n.message+="\nPlease report this to https://github.com/markedjs/marked.",e){const e="

    An error occurred:

    "+W(n.message+"",!0)+"
    ";return t?Promise.resolve(e):e}if(t)return Promise.reject(n);throw n}}}const he=new ce;function pe(e,t){return he.parse(e,t)}pe.options=pe.setOptions=function(e){return he.setOptions(e),pe.defaults=he.defaults,n(pe.defaults),pe},pe.getDefaults=t,pe.defaults=e.defaults,pe.use=function(...e){return he.use(...e),pe.defaults=he.defaults,n(pe.defaults),pe},pe.walkTokens=function(e,t){return he.walkTokens(e,t)},pe.parseInline=he.parseInline,pe.Parser=oe,pe.parser=oe.parse,pe.Renderer=ie,pe.TextRenderer=le,pe.Lexer=re,pe.lexer=re.lex,pe.Tokenizer=se,pe.Hooks=ae,pe.parse=pe;const ue=pe.options,ge=pe.setOptions,ke=pe.use,de=pe.walkTokens,fe=pe.parseInline,xe=pe,be=oe.parse,we=re.lex;e.Hooks=ae,e.Lexer=re,e.Marked=ce,e.Parser=oe,e.Renderer=ie,e.TextRenderer=le,e.Tokenizer=se,e.getDefaults=t,e.lexer=we,e.marked=pe,e.options=ue,e.parse=xe,e.parseInline=fe,e.parser=be,e.setOptions=ge,e.use=ke,e.walkTokens=de})); diff --git a/frontends/desktop/scripts/assert-dist-built.mjs b/frontends/desktop/scripts/assert-dist-built.mjs index b7046a5dd..34ad4debb 100644 --- a/frontends/desktop/scripts/assert-dist-built.mjs +++ b/frontends/desktop/scripts/assert-dist-built.mjs @@ -15,6 +15,10 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { + REMOVED_LEGACY_REACT_PUBLIC_ASSETS, + REQUIRED_REACT_PUBLIC_ASSETS, +} from './react-public-assets.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const DESKTOP_ROOT = path.resolve(__dirname, '..'); @@ -30,17 +34,28 @@ export function checkDistBuilt(distDir) { // React v2 public assets are an independent packaging boundary. The upstream static v1 tree is // deliberately not read here; its zero-diff invariant is enforced against the PR base in CI. - for (const publicAsset of ['fallback.html', 'i18n.js', 'styles.css']) { + for (const publicAsset of REQUIRED_REACT_PUBLIC_ASSETS) { const sourcePath = path.join(DESKTOP_ROOT, 'public', publicAsset); if (!fs.existsSync(sourcePath) || fs.statSync(sourcePath).size === 0) { return { ok: false, error: `public/${publicAsset} is missing or empty` }; } } + for (const publicAsset of REMOVED_LEGACY_REACT_PUBLIC_ASSETS) { + if (fs.existsSync(path.join(DESKTOP_ROOT, 'public', publicAsset))) { + return { ok: false, error: `dead React v2 public asset was restored: public/${publicAsset}` }; + } + } // 1. dist/ exists if (!fs.existsSync(distDir)) { return { ok: false, error: `no dist directory at ${distDir}` }; } + for (const publicAsset of REQUIRED_REACT_PUBLIC_ASSETS) { + const builtPath = path.join(distDir, publicAsset); + if (!fs.existsSync(builtPath) || fs.statSync(builtPath).size === 0) { + return { ok: false, error: `${publicAsset} was not copied to dist/` }; + } + } // 2. index.html exists and non-empty const indexPath = path.join(distDir, 'index.html'); @@ -77,6 +92,11 @@ export function checkDistBuilt(distDir) { return { ok: false, error: `fallback.html is missing the ${command} recovery contract` }; } } + for (const publicAsset of REMOVED_LEGACY_REACT_PUBLIC_ASSETS) { + if (fs.existsSync(path.join(distDir, publicAsset))) { + return { ok: false, error: `dead React v2 public asset was copied to dist: ${publicAsset}` }; + } + } // 4. assets/ contains at least one JS bundle const assetsDir = path.join(distDir, 'assets'); @@ -147,6 +167,7 @@ if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToP console.log(` ✓ dist/index.html present`); console.log(` ✓ dist/loading.html present`); console.log(` ✓ dual-contract loading and recovery assets are present`); + console.log(` ✓ dead legacy React v2 public assets and fonts are absent`); console.log(` ✓ React bundles contain no Desktop v1 gaLegacy dependency`); console.log(` ✓ ${jsFiles.length} JS bundle(s), ${cssFiles.length} CSS file(s)`); console.log(` ✓ Total assets size: ${(totalSize / 1024).toFixed(0)} KB`); diff --git a/frontends/desktop/scripts/react-public-assets.mjs b/frontends/desktop/scripts/react-public-assets.mjs new file mode 100644 index 000000000..835c05b28 --- /dev/null +++ b/frontends/desktop/scripts/react-public-assets.mjs @@ -0,0 +1,17 @@ +export const REQUIRED_REACT_PUBLIC_ASSETS = [ + 'fallback.html', + 'assets/ga-logo.svg', +]; + +export const REMOVED_LEGACY_REACT_PUBLIC_ASSETS = [ + 'styles.css', + 'i18n.js', + 'phosphor-icons.js', + 'vendor/marked.min.js', + 'assets/fonts/fonts.css', + 'assets/fonts/README.md', + 'assets/fonts/azonix-wordmark.woff2', + 'assets/fonts/jetbrains-mono-latin.woff2', + 'assets/fonts/lexend-latin.woff2', + 'assets/fonts/noto-sans-latin.woff2', +]; diff --git a/frontends/desktop/scripts/verify-ci-contract.mjs b/frontends/desktop/scripts/verify-ci-contract.mjs index ef90a9b3a..e9659d676 100644 --- a/frontends/desktop/scripts/verify-ci-contract.mjs +++ b/frontends/desktop/scripts/verify-ci-contract.mjs @@ -1,6 +1,10 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { + REMOVED_LEGACY_REACT_PUBLIC_ASSETS, + REQUIRED_REACT_PUBLIC_ASSETS, +} from './react-public-assets.mjs'; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const desktopRoot = path.resolve(scriptDir, '..'); @@ -182,10 +186,15 @@ check( ); console.log('\n[4] bootstrap and window capability contract'); -for (const relativePath of ['public/fallback.html', 'public/i18n.js', 'public/styles.css']) { +for (const publicAsset of REQUIRED_REACT_PUBLIC_ASSETS) { + const relativePath = `public/${publicAsset}`; const absolutePath = path.join(desktopRoot, relativePath); check(fs.existsSync(absolutePath) && fs.statSync(absolutePath).size > 0, `${relativePath} is present`); } +for (const publicAsset of REMOVED_LEGACY_REACT_PUBLIC_ASSETS) { + const relativePath = `public/${publicAsset}`; + check(!fs.existsSync(path.join(desktopRoot, relativePath)), `${relativePath} stays removed from React v2`); +} const capability = readJson('src-tauri/capabilities/default.json'); const permissions = new Set(capability.permissions ?? []); diff --git a/frontends/desktop/src/__tests__/external-link-interceptor.test.ts b/frontends/desktop/src/__tests__/external-link-interceptor.test.ts index ff891b594..2c8cfb121 100644 --- a/frontends/desktop/src/__tests__/external-link-interceptor.test.ts +++ b/frontends/desktop/src/__tests__/external-link-interceptor.test.ts @@ -1,38 +1,28 @@ // @vitest-environment happy-dom -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest'; +import { handleRenderedContentLinkClick } from '../lib/rendered-content-policy'; /** * Verifies that the global click delegate in main.tsx intercepts * external links and routes them to tauri-plugin-opener. */ describe('external link interceptor', () => { - let openUrl: ReturnType; + let openUrl: Mock<(url: string) => void>; let cleanup: () => void; function installInterceptor() { - const handler = (e: MouseEvent) => { - const anchor = (e.target as HTMLElement).closest('a[href]') as HTMLAnchorElement | null; - if (!anchor) return; - const href = anchor.href; - if (!href || href.startsWith('javascript:')) return; - const url = new URL(href, location.href); - if (url.origin === location.origin) return; - e.preventDefault(); - (window as any).__TAURI__.opener.openUrl(href); - }; + const handler = (event: MouseEvent) => handleRenderedContentLinkClick(event, (url) => openUrl(url)); document.addEventListener('click', handler); return () => document.removeEventListener('click', handler); } beforeEach(() => { - openUrl = vi.fn(); - (window as any).__TAURI__ = { opener: { openUrl } }; + openUrl = vi.fn<(url: string) => void>(); cleanup = installInterceptor(); }); afterEach(() => { cleanup(); - delete (window as any).__TAURI__; }); it('intercepts external http links and calls opener.openUrl', () => { @@ -63,7 +53,7 @@ describe('external link interceptor', () => { document.body.removeChild(a); }); - it('does not intercept javascript: links', () => { + it('prevents javascript: links without invoking the opener', () => { const a = document.createElement('a'); a.href = 'javascript:void(0)'; a.textContent = 'Noop'; @@ -72,6 +62,26 @@ describe('external link interceptor', () => { const ev = new MouseEvent('click', { bubbles: true, cancelable: true }); a.dispatchEvent(ev); + expect(ev.defaultPrevented).toBe(true); + expect(openUrl).not.toHaveBeenCalled(); + document.body.removeChild(a); + }); + + it.each([ + 'file:///etc/passwd', + 'data:text/html,', + 'blob:https://example.com/id', + '//example.com/protocol-relative', + '/relative/path', + ])('prevents non-web or non-explicit link %s', (href) => { + const a = document.createElement('a'); + a.setAttribute('href', href); + document.body.appendChild(a); + + const ev = new MouseEvent('click', { bubbles: true, cancelable: true }); + a.dispatchEvent(ev); + + expect(ev.defaultPrevented).toBe(true); expect(openUrl).not.toHaveBeenCalled(); document.body.removeChild(a); }); @@ -94,6 +104,19 @@ describe('external link interceptor', () => { document.body.removeChild(a); }); + it('intercepts KaTeX-style MathML href nodes', () => { + const mathLink = document.createElement('mrow'); + mathLink.setAttribute('href', 'https://example.com/formula'); + document.body.appendChild(mathLink); + + const ev = new MouseEvent('click', { bubbles: true, cancelable: true }); + mathLink.dispatchEvent(ev); + + expect(ev.defaultPrevented).toBe(true); + expect(openUrl).toHaveBeenCalledWith('https://example.com/formula'); + document.body.removeChild(mathLink); + }); + it('ignores clicks on non-anchor elements', () => { const div = document.createElement('div'); div.textContent = 'just a div'; diff --git a/frontends/desktop/src/__tests__/rendered-content-policy.test.ts b/frontends/desktop/src/__tests__/rendered-content-policy.test.ts new file mode 100644 index 000000000..2a5ce2fbc --- /dev/null +++ b/frontends/desktop/src/__tests__/rendered-content-policy.test.ts @@ -0,0 +1,87 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import katex from 'katex'; +import { + normalizeExternalHttpUrl, + normalizeMarkdownImageUrl, + trustKatexCommand, +} from '../lib/rendered-content-policy'; + +describe('rendered content URL policy', () => { + it('allows explicit HTTP(S) links and rejects every other link form', () => { + expect(normalizeExternalHttpUrl('https://example.com/docs?q=1')).toBe('https://example.com/docs?q=1'); + expect(normalizeExternalHttpUrl('http://example.com')).toBe('http://example.com/'); + + for (const value of [ + 'javascript:alert(1)', + 'file:///etc/passwd', + 'data:text/html,', + 'blob:https://example.com/id', + '//example.com/path', + '/relative/path', + 'https:\\example.com', + ]) { + expect(normalizeExternalHttpUrl(value)).toBeNull(); + } + }); + + it('blocks remote Markdown images but keeps bounded local image sources', () => { + expect(normalizeMarkdownImageUrl('https://tracker.example/pixel.png')).toBeNull(); + expect(normalizeMarkdownImageUrl('http://127.0.0.1:14168/tracker.png')).toBeNull(); + expect(normalizeMarkdownImageUrl('//tracker.example/pixel.png')).toBeNull(); + expect(normalizeMarkdownImageUrl('file:///etc/passwd')).toBeNull(); + expect(normalizeMarkdownImageUrl('data:image/svg+xml,')).toBeNull(); + expect(normalizeMarkdownImageUrl('data:text/html,')).toBeNull(); + + expect(normalizeMarkdownImageUrl('data:image/png;base64,AAAA')).toBe('data:image/png;base64,AAAA'); + expect(normalizeMarkdownImageUrl('blob:https://app.local/79d8')).toBe('blob:https://app.local/79d8'); + expect(normalizeMarkdownImageUrl('asset://localhost/tmp/diagram.png')).toBe('asset://localhost/tmp/diagram.png'); + expect(normalizeMarkdownImageUrl('asset://remote.example/tmp/diagram.png')).toBeNull(); + expect(normalizeMarkdownImageUrl('http://asset.localhost/tmp/diagram.png')).toBe('http://asset.localhost/tmp/diagram.png'); + expect(normalizeMarkdownImageUrl('/assets/ga-logo.svg')).toBe('/assets/ga-logo.svg'); + expect(normalizeMarkdownImageUrl('assets/preview.png?v=1')).toBe('assets/preview.png?v=1'); + expect(normalizeMarkdownImageUrl('/assets/../fallback.html')).toBeNull(); + expect(normalizeMarkdownImageUrl('/assets/%2e%2e/fallback.html')).toBeNull(); + }); +}); + +describe('KaTeX trust policy', () => { + it('keeps ordinary formulas renderable', () => { + const html = katex.renderToString('\\frac{1}{n} \\sum_{i=1}^{n} x_i', { + throwOnError: true, + trust: trustKatexCommand, + }); + expect(html).toContain('katex'); + expect(html).toContain('frac'); + }); + + it('allows only explicit HTTP(S) href/url commands', () => { + expect(trustKatexCommand({ command: '\\href', url: 'https://example.com', protocol: 'https' })).toBe(true); + expect(trustKatexCommand({ command: '\\url', url: 'http://example.com', protocol: 'http' })).toBe(true); + expect(trustKatexCommand({ command: '\\href', url: 'javascript:alert(1)', protocol: 'javascript' })).toBe(false); + expect(trustKatexCommand({ command: '\\href', url: 'file:///etc/passwd', protocol: 'file' })).toBe(false); + expect(trustKatexCommand({ command: '\\href', url: 'data:text/html,boom', protocol: 'data' })).toBe(false); + expect(trustKatexCommand({ command: '\\href', url: '/relative', protocol: '_relative' })).toBe(false); + }); + + it('rejects every resource and arbitrary HTML command', () => { + expect(trustKatexCommand({ command: '\\includegraphics', url: 'https://example.com/pixel.png', protocol: 'https' })).toBe(false); + expect(trustKatexCommand({ command: '\\includegraphics', url: 'data:image/png;base64,AAAA', protocol: 'data' })).toBe(false); + expect(trustKatexCommand({ command: '\\htmlStyle', style: 'background:url(https://example.com)' })).toBe(false); + expect(trustKatexCommand({ command: '\\htmlClass', class: 'arbitrary' })).toBe(false); + }); + + it('does not emit dangerous links or external images', () => { + const dangerousLink = katex.renderToString('\\href{javascript:alert(1)}{bad}', { + throwOnError: false, + trust: trustKatexCommand, + }); + const externalImage = katex.renderToString('\\includegraphics{https://example.com/pixel.png}', { + throwOnError: false, + trust: trustKatexCommand, + }); + + expect(dangerousLink).not.toMatch(/href=["']javascript:/i); + expect(externalImage).not.toContain(' { + it('does not give remote Markdown images a loadable src', () => { + const { container } = render( + <> + + + , + ); + + expect(container.querySelector('img')).toBeNull(); + expect(screen.getByText('tracking pixel').getAttribute('data-slot')).toBe('md-image-blocked'); + expect(screen.getByText('summary tracker').getAttribute('data-slot')).toBe('md-image-blocked'); + }); + + it('keeps a bounded embedded bitmap image available', () => { + render(); + + const image = screen.getByRole('img', { name: 'local preview' }); + expect(image.getAttribute('src')).toBe('data:image/png;base64,AAAA'); + expect(image.getAttribute('loading')).toBe('lazy'); + }); + + it('renders ordinary formulas in Markdown and summaries', () => { + const { container } = render( + <> + + + , + ); + + expect(container.querySelectorAll('.katex')).toHaveLength(2); + }); + + it('keeps an ordinary HTTPS link actionable and hardened', () => { + render(); + + const link = screen.getByRole('link', { name: 'documentation' }); + expect(link.getAttribute('href')).toBe('https://example.com/docs'); + expect(link.getAttribute('target')).toBe('_blank'); + expect(link.getAttribute('rel')).toBe('noopener noreferrer'); + }); + + it('allows an explicit HTTPS KaTeX link through the same URL boundary', () => { + const { container } = render( + , + ); + + const linkedNodes = [...container.querySelectorAll('[href]')]; + expect(linkedNodes.length).toBeGreaterThan(0); + expect(linkedNodes.every((node) => node.getAttribute('href') === 'https://example.com/formula')).toBe(true); + }); + + it('removes dangerous Markdown links and KaTeX resource commands', () => { + const { container } = render( + , + ); + + expect(container.querySelector('a')).toBeNull(); + expect(container.querySelector('img')).toBeNull(); + expect(screen.getByText('run').getAttribute('data-slot')).toBe('md-link-blocked'); + }); +}); diff --git a/frontends/desktop/src/components/chat/Thread/parts/MarkdownPart.tsx b/frontends/desktop/src/components/chat/Thread/parts/MarkdownPart.tsx index ce951e4bd..74d601249 100644 --- a/frontends/desktop/src/components/chat/Thread/parts/MarkdownPart.tsx +++ b/frontends/desktop/src/components/chat/Thread/parts/MarkdownPart.tsx @@ -9,8 +9,13 @@ import { CodeBlock } from './CodeBlock'; import { DiffLines } from './DiffLines'; import { SafeMathBlock } from './SafeMath'; import { HugeTextFallback } from './HugeTextFallback'; +import { SAFE_MARKDOWN_COMPONENTS } from './SafeMarkdownComponents'; import { useSmoothReveal } from '../../../../hooks/useSmoothReveal'; import { preprocessMarkdown } from '../../../../lib/markdown-preprocess'; +import { + SAFE_KATEX_OPTIONS, + renderedContentUrlTransform, +} from '../../../../lib/rendered-content-policy'; const KATEX_OPTIONS = { macros: { @@ -23,8 +28,7 @@ const KATEX_OPTIONS = { '\\norm': '\\left\\lVert #1 \\right\\rVert', '\\abs': '\\left\\lvert #1 \\right\\rvert', }, - strict: 'ignore' as const, - trust: true, + ...SAFE_KATEX_OPTIONS, errorColor: 'var(--semi-color-text-2)', }; @@ -38,6 +42,7 @@ interface Props { function makeComponents(isStreaming: boolean): Components { return { + ...SAFE_MARKDOWN_COMPONENTS, pre({ children }) { return <>{children}; }, @@ -92,6 +97,7 @@ export const MarkdownPart = memo(function MarkdownPart({ content, isStreaming = remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[[rehypeKatex, KATEX_OPTIONS]]} components={components} + urlTransform={renderedContentUrlTransform} > {preprocessMarkdown(deferredText)} diff --git a/frontends/desktop/src/components/chat/Thread/parts/SafeMarkdownComponents.tsx b/frontends/desktop/src/components/chat/Thread/parts/SafeMarkdownComponents.tsx new file mode 100644 index 000000000..cbf190676 --- /dev/null +++ b/frontends/desktop/src/components/chat/Thread/parts/SafeMarkdownComponents.tsx @@ -0,0 +1,40 @@ +import type { Components } from 'react-markdown'; +import { + normalizeExternalHttpUrl, + normalizeMarkdownImageUrl, +} from '../../../../lib/rendered-content-policy'; + +/** Shared defense-in-depth components for every model-authored Markdown surface. */ +export const SAFE_MARKDOWN_COMPONENTS: Components = { + a({ href, children, node: _node, ...props }) { + const safeHref = normalizeExternalHttpUrl(href ?? ''); + if (!safeHref) return {children}; + + return ( +
    + {children} + + ); + }, + img({ src, alt, node: _node, ...props }) { + const safeSrc = normalizeMarkdownImageUrl(src ?? ''); + if (!safeSrc) { + return alt ? {alt} : null; + } + + return ( + {alt + ); + }, +}; diff --git a/frontends/desktop/src/components/chat/Thread/parts/SummaryPart.tsx b/frontends/desktop/src/components/chat/Thread/parts/SummaryPart.tsx index 284a2cd56..690a43c29 100644 --- a/frontends/desktop/src/components/chat/Thread/parts/SummaryPart.tsx +++ b/frontends/desktop/src/components/chat/Thread/parts/SummaryPart.tsx @@ -3,11 +3,16 @@ import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import remarkMath from 'remark-math'; import rehypeKatex from 'rehype-katex'; +import 'katex/dist/katex.min.css'; +import { SAFE_MARKDOWN_COMPONENTS } from './SafeMarkdownComponents'; import { preprocessMarkdown } from '../../../../lib/markdown-preprocess'; +import { + SAFE_KATEX_OPTIONS, + renderedContentUrlTransform, +} from '../../../../lib/rendered-content-policy'; const KATEX_OPTIONS = { - strict: 'ignore' as const, - trust: true, + ...SAFE_KATEX_OPTIONS, }; interface Props { @@ -20,6 +25,8 @@ export const SummaryPart = memo(function SummaryPart({ content }: Props) { {preprocessMarkdown(content)} diff --git a/frontends/desktop/src/lib/katex-memo.ts b/frontends/desktop/src/lib/katex-memo.ts index bdd8dafb0..1afd33003 100644 --- a/frontends/desktop/src/lib/katex-memo.ts +++ b/frontends/desktop/src/lib/katex-memo.ts @@ -9,6 +9,7 @@ */ import katex from 'katex'; +import { trustKatexCommand } from './rendered-content-policy'; // --------------------------------------------------------------------------- // LRU Cache @@ -77,7 +78,12 @@ export function renderMathCached( // Level 1: strict try { - html = katex.renderToString(value, { displayMode, throwOnError: true, macros }); + html = katex.renderToString(value, { + displayMode, + throwOnError: true, + macros, + trust: trustKatexCommand, + }); cache.set(key, html); return html; } catch { @@ -92,6 +98,7 @@ export function renderMathCached( strict: 'ignore', errorColor, macros, + trust: trustKatexCommand, }); cache.set(key, html); return html; diff --git a/frontends/desktop/src/lib/rendered-content-policy.ts b/frontends/desktop/src/lib/rendered-content-policy.ts new file mode 100644 index 000000000..5b71b4fd7 --- /dev/null +++ b/frontends/desktop/src/lib/rendered-content-policy.ts @@ -0,0 +1,149 @@ +import type { KatexOptions, TrustContext } from 'katex'; +import type { UrlTransform } from 'react-markdown'; + +const HTTP_PROTOCOLS = new Set(['http:', 'https:']); +const KATEX_LINK_COMMANDS = new Set(['\\href', '\\url']); +const SAFE_DATA_IMAGE = /^data:image\/(?:avif|gif|jpeg|png|webp);base64,[a-z0-9+/]+={0,2}$/i; +const LOCAL_ASSET_PATH = /^(?:\/|\.\/)?assets\//; +const ENCODED_PATH_SEPARATOR_OR_DOT = /%(?:2e|2f|5c)/i; +const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/; + +/** + * Only browser-safe external web links are actionable in rendered model output. + * Requiring `//` after the scheme also rejects relative and protocol-relative URLs. + */ +export function normalizeExternalHttpUrl(value: string): string | null { + const candidate = value.trim(); + if (!/^https?:\/\//i.test(candidate) || CONTROL_CHARACTER.test(candidate)) return null; + + try { + const url = new URL(candidate); + return HTTP_PROTOCOLS.has(url.protocol) ? url.href : null; + } catch { + return null; + } +} + +function normalizeLocalAssetPath(value: string): string | null { + if ( + !LOCAL_ASSET_PATH.test(value) + || value.startsWith('//') + || value.includes('\\') + || ENCODED_PATH_SEPARATOR_OR_DOT.test(value) + ) { + return null; + } + + try { + const url = new URL(value, 'https://genericagent.invalid/'); + return url.origin === 'https://genericagent.invalid' + && url.pathname.startsWith('/assets/') + ? value + : null; + } catch { + return null; + } +} + +/** + * Markdown images are local-first. Remote HTTP(S) URLs never receive a `src`. + * Allowed sources are bounded bitmap data URLs, in-page blob URLs, Tauri asset + * protocol URLs, and files under the renderer's own `/assets/` directory. + */ +export function normalizeMarkdownImageUrl(value: string): string | null { + const candidate = value.trim(); + if (!candidate || CONTROL_CHARACTER.test(candidate)) return null; + + if (SAFE_DATA_IMAGE.test(candidate)) return candidate; + if (/^blob:/i.test(candidate)) { + try { + return new URL(candidate).protocol === 'blob:' ? candidate : null; + } catch { + return null; + } + } + if (/^asset:/i.test(candidate)) { + try { + const url = new URL(candidate); + return url.protocol === 'asset:' + && (url.hostname === '' || url.hostname === 'localhost') + && !url.username + && !url.password + ? candidate + : null; + } catch { + return null; + } + } + + // Tauri uses this host form for converted local asset URLs on some platforms. + if (/^https?:\/\/asset\.localhost(?:\/|$)/i.test(candidate)) { + try { + const url = new URL(candidate); + return url.hostname === 'asset.localhost' && HTTP_PROTOCOLS.has(url.protocol) + ? url.href + : null; + } catch { + return null; + } + } + + return normalizeLocalAssetPath(candidate); +} + +/** ReactMarkdown applies this policy after remark/rehype plugins have run. */ +export const renderedContentUrlTransform: UrlTransform = (value, key, node) => { + if (key === 'href') return normalizeExternalHttpUrl(value) ?? undefined; + if (key === 'src' && node.tagName === 'img') return normalizeMarkdownImageUrl(value) ?? undefined; + return undefined; +}; + +/** + * KaTeX may create links, but never images or arbitrary HTML attributes/styles. + * Relative URLs and non-HTTP(S) protocols are rejected before markup is emitted. + */ +export function trustKatexCommand(context: TrustContext): boolean { + if (!KATEX_LINK_COMMANDS.has(context.command) || !('url' in context)) return false; + return normalizeExternalHttpUrl(context.url) !== null; +} + +export const SAFE_KATEX_OPTIONS = { + strict: 'ignore', + trust: trustKatexCommand, +} satisfies KatexOptions; + +export type ExternalUrlOpener = (url: string) => unknown; + +/** + * Block unsafe link navigation in every renderer. In Tauri, allowed external + * links are delegated to the opener plugin instead of navigating the webview. + */ +export function handleRenderedContentLinkClick( + event: MouseEvent, + openExternal?: ExternalUrlOpener, +): void { + const target = event.target; + if (!(target instanceof Element)) return; + + // KaTeX emits `href` on both HTML anchors and hidden accessible MathML nodes. + const link = target.closest('[href]'); + if (!link) return; + + const safeUrl = normalizeExternalHttpUrl(link.getAttribute('href') ?? ''); + if (!safeUrl) { + event.preventDefault(); + return; + } + + const destination = new URL(safeUrl); + if (!openExternal || destination.origin === location.origin) return; + + event.preventDefault(); + try { + void Promise.resolve(openExternal(safeUrl)).catch((error: unknown) => { + console.error('[external-link] opener failed:', error); + }); + } catch (error) { + console.error('[external-link] opener failed:', error); + } +} diff --git a/frontends/desktop/src/main.tsx b/frontends/desktop/src/main.tsx index b96f03b89..3b12c27ca 100644 --- a/frontends/desktop/src/main.tsx +++ b/frontends/desktop/src/main.tsx @@ -2,23 +2,19 @@ import './platform'; import '@semi-css'; import './global.css'; import './stores/bridgeActivity'; +import { handleRenderedContentLinkClick } from './lib/rendered-content-policy'; if (document.documentElement.dataset.appearance === 'dark') { document.body.setAttribute('theme-mode', 'dark'); } -if ((window as any).__TAURI__) { - document.addEventListener('click', (e) => { - const anchor = (e.target as HTMLElement).closest('a[href]') as HTMLAnchorElement | null; - if (!anchor) return; - const href = anchor.href; - if (!href || href.startsWith('javascript:')) return; - const url = new URL(href, location.href); - if (url.origin === location.origin) return; - e.preventDefault(); - (window as any).__TAURI__.opener.openUrl(href); - }); -} +document.addEventListener('click', (event) => { + const opener = (window as any).__TAURI__?.opener; + handleRenderedContentLinkClick( + event, + typeof opener?.openUrl === 'function' ? (url) => opener.openUrl(url) : undefined, + ); +}); setTimeout(() => { document.body.classList.remove('no-transition'); From fe5a53853a95e0fc65be24c8f677865d8eaea0ab Mon Sep 17 00:00:00 2001 From: abraxas914 Date: Sat, 22 Aug 2026 23:17:57 +0800 Subject: [PATCH 2/5] fix(desktop): make data import transactional --- frontends/cost_tracker.py | 5 + frontends/data_backup.py | 408 ++++++++++++++++++-- frontends/desktop_bridge.py | 22 +- frontends/tests/test_bridge_sessions.py | 35 ++ frontends/tests/test_bridge_utils.py | 63 ++- frontends/tests/test_cost_tracker_ledger.py | 87 +++++ frontends/tests/test_data_backup.py | 268 ++++++++++++- 7 files changed, 835 insertions(+), 53 deletions(-) diff --git a/frontends/cost_tracker.py b/frontends/cost_tracker.py index 0471129fb..c83ccfe7c 100644 --- a/frontends/cost_tracker.py +++ b/frontends/cost_tracker.py @@ -162,6 +162,11 @@ def init_ledger(root: str) -> None: def _append_ledger(thread_key: str, inp: int, out: int, cc: int, cr: int) -> None: global _ledger_uncompacted_bytes + # TUI and conductor processes install the in-memory tracker but never call + # init_ledger(). Keep their historical hot path to one lock-free branch: + # no clock lookup, JSON encoding, byte counting, or ledger lock acquisition. + if _ledger_fd is None: + return line = json.dumps( {"t": time.time(), "k": thread_key, "i": inp, "o": out, "cc": cc, "cr": cr}, separators=(",", ":"), diff --git a/frontends/data_backup.py b/frontends/data_backup.py index 4d711fff8..964d563e7 100644 --- a/frontends/data_backup.py +++ b/frontends/data_backup.py @@ -3,14 +3,17 @@ import contextlib import datetime as dt +import errno import json import os +import re import shutil import stat import tempfile +import uuid import zipfile from pathlib import Path, PurePosixPath -from typing import Iterator +from typing import Any, Callable, Iterable, Iterator BACKUP_SCHEMA = "genericagent.data-backup" @@ -24,6 +27,8 @@ PurePosixPath("temp/desktop_sessions"), ) +_DESKTOP_SESSION_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,127}") + class BackupFormatError(ValueError): """Raised when a backup cannot be trusted or is not compatible.""" @@ -210,6 +215,11 @@ def _validated_zip(archive: zipfile.ZipFile) -> tuple[dict, list[zipfile.ZipInfo def _legacy_inspection(root: Path) -> dict: files = _source_files(root) paths = [relative for _, relative in files] + has_data_folder = any( + (root.joinpath(*prefix.parts).is_dir() + and not root.joinpath(*prefix.parts).is_symlink()) + for prefix in _DATA_PREFIXES[:2] + ) legacy_session_files = [ root / "temp" / "desktop_sessions.json", root / "temp" / "desktop_sessions.json.migrated", @@ -217,8 +227,10 @@ def _legacy_inspection(root: Path) -> dict: legacy_session_count = sum( path.is_file() and not path.is_symlink() for path in legacy_session_files ) - if not files and not legacy_session_count: - raise BackupFormatError("no memory or session data found") + if not has_data_folder: + raise BackupFormatError( + "not a GA directory (no memory/ or temp/model_responses/)" + ) counts = _content_counts(paths) counts["sessions"] += legacy_session_count return { @@ -276,47 +288,381 @@ def materialize_import_source(source_path: str) -> Iterator[Path]: yield target_root -def merge_data_files(source_dir: str, ga_root: str) -> dict: +def _is_desktop_session_id(value: object) -> bool: + session_id = str(value or "") + return ( + not session_id.startswith("tui_") + and _DESKTOP_SESSION_ID_RE.fullmatch(session_id) is not None + ) + + +def _read_source_sessions(source: Path) -> tuple[list[dict], bool, int]: + """Read supported Desktop session stores without trusting filenames. + + Corrupt/non-Desktop records are skipped. The skipped count is intentionally + record-oriented; an unreadable file counts as one skipped source record. + """ + items: list[dict] = [] + found = False + skipped = 0 + sessions_dir = source / "temp" / "desktop_sessions" + if sessions_dir.is_dir() and not sessions_dir.is_symlink(): + for session_file in sorted(sessions_dir.glob("*.json")): + if session_file.is_symlink() or not session_file.is_file(): + continue + found = True + try: + item = json.loads(session_file.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError, ValueError): + skipped += 1 + continue + if isinstance(item, dict): + items.append(item) + else: + skipped += 1 + + for legacy in ( + source / "temp" / "desktop_sessions.json", + source / "temp" / "desktop_sessions.json.migrated", + ): + if legacy.is_symlink() or not legacy.is_file(): + continue + found = True + try: + document = json.loads(legacy.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError, ValueError): + skipped += 1 + continue + if not isinstance(document, list): + skipped += 1 + continue + for item in document: + if isinstance(item, dict): + items.append(item) + else: + skipped += 1 + return items, found, skipped + + +def _existing_session_ids(destination_root: Path) -> set[str]: + ids: set[str] = set() + sessions_dir = destination_root / "temp" / "desktop_sessions" + if not sessions_dir.is_dir() or sessions_dir.is_symlink(): + return ids + for session_file in sorted(sessions_dir.glob("*.json")): + if session_file.is_symlink() or not session_file.is_file(): + continue + if _is_desktop_session_id(session_file.stem): + ids.add(session_file.stem) + try: + item = json.loads(session_file.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError, ValueError): + continue + if isinstance(item, dict) and _is_desktop_session_id(item.get("id")): + ids.add(str(item["id"])) + return ids + + +def _remove_path(path: Path) -> None: + if path.is_symlink() or path.is_file(): + path.unlink() + elif path.is_dir(): + shutil.rmtree(path) + + +def _copy_tree_strict(source: Path, destination: Path) -> None: + """Copy a complete tree while refusing links and special files.""" + if source.is_symlink() or not source.is_dir(): + raise ValueError(f"cannot safely copy data folder: {source}") + destination.mkdir(parents=True, exist_ok=False) + for item in sorted(source.iterdir(), key=lambda path: path.name): + if item.is_symlink(): + raise ValueError(f"data folder contains a symbolic link: {item}") + target = destination / item.name + if item.is_dir(): + _copy_tree_strict(item, target) + elif item.is_file(): + shutil.copy2(item, target) + else: + raise ValueError(f"data folder contains an unsupported file: {item}") + + +def _prepare_overlay_target(root: Path, relative: PurePosixPath) -> Path: + current = root + for part in relative.parts[:-1]: + current = current / part + if current.is_symlink(): + raise ValueError(f"memory destination contains a symbolic link: {current}") + if current.exists() and not current.is_dir(): + _remove_path(current) + current.mkdir(exist_ok=True) + target = root.joinpath(*relative.parts) + if target.is_symlink() or target.is_dir(): + _remove_path(target) + return target + + +def _assert_safe_new_target(root: Path, target: Path) -> None: + relative = target.relative_to(root) + current = root + for part in relative.parts[:-1]: + current = current / part + if current.is_symlink(): + raise ValueError(f"data destination contains a symbolic link: {current}") + if current.exists() and not current.is_dir(): + raise ValueError(f"data destination parent is not a directory: {current}") + + +def _new_backup_path(destination_root: Path) -> Path: + timestamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S_%f") + backup_parent = destination_root / "temp" + for suffix in range(1000): + tail = "" if suffix == 0 else f"_{suffix}" + candidate = backup_parent / f"memory_import_backup_{timestamp}{tail}" + if not candidate.exists() and not candidate.is_symlink(): + return candidate + raise OSError("cannot allocate a unique memory backup directory") + + +def _create_memory_backup(destination_root: Path, memory_root: Path) -> Path: + backup_dir = _new_backup_path(destination_root) + backup_parent = backup_dir.parent + if backup_parent.is_symlink() or ( + backup_parent.exists() and not backup_parent.is_dir() + ): + raise ValueError("current backup destination is not a safe directory") + backup_parent.mkdir(parents=True, exist_ok=True) + staging = backup_parent / f".{backup_dir.name}.staging-{uuid.uuid4().hex}" + try: + staging.mkdir() + _copy_tree_strict(memory_root, staging / "memory") + os.replace(staging, backup_dir) + except Exception: + with contextlib.suppress(OSError): + _remove_path(staging) + raise + return backup_dir + + +def _install_file_add_only(staged: Path, target: Path) -> None: + """Install one staged file without ever replacing an existing path.""" + try: + os.link(staged, target, follow_symlinks=False) + except OSError as error: + unsupported_link_errors = { + errno.EINVAL, + errno.EPERM, + errno.EXDEV, + getattr(errno, "ENOTSUP", errno.EINVAL), + getattr(errno, "EOPNOTSUPP", errno.EINVAL), + } + if error.errno not in unsupported_link_errors: + raise + + # Some removable/network filesystems do not implement hard links. + # O_EXCL retains the add-only contract there; a failed copy removes + # only the path this call successfully created. + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + flags |= getattr(os, "O_BINARY", 0) + descriptor: int | None = None + created = False + try: + descriptor = os.open(target, flags, stat.S_IMODE(staged.stat().st_mode)) + created = True + with staged.open("rb") as reader, os.fdopen(descriptor, "wb") as writer: + descriptor = None + shutil.copyfileobj(reader, writer) + writer.flush() + os.fsync(writer.fileno()) + except Exception: + if descriptor is not None: + with contextlib.suppress(OSError): + os.close(descriptor) + if created: + with contextlib.suppress(OSError): + target.unlink() + raise + with contextlib.suppress(OSError): + staged.unlink() + + +def merge_data_files( + source_dir: str, + ga_root: str, + *, + existing_session_ids: Iterable[str] | None = None, + session_preparer: Callable[[dict], object] | None = None, +) -> dict[str, Any]: + """Transactionally merge a validated data tree into ``ga_root``. + + ``memory`` is source-wins after a durable full backup, responses and + Desktop sessions are add-only, and any activation error rolls back files + installed by this call. ``session_preparer`` lets the bridge validate and + construct its in-memory Session objects before any destination is changed. + """ source = Path(source_dir).expanduser().resolve() destination_root = Path(ga_root).expanduser().resolve() if not source.is_dir(): raise ValueError("source data folder is unavailable") + if not destination_root.is_dir(): + raise ValueError("current data destination is unavailable") if source == destination_root: raise ValueError("source is the same as current data") inspection = _legacy_inspection(source) - memory_copied = 0 - memory_skipped = 0 - responses_copied = 0 + source_memory = source.joinpath(*_DATA_PREFIXES[0].parts) + source_responses = source.joinpath(*_DATA_PREFIXES[1].parts) + destination_memory = destination_root.joinpath(*_DATA_PREFIXES[0].parts) + destination_responses = destination_root.joinpath(*_DATA_PREFIXES[1].parts) + has_source_memory = source_memory.is_dir() and not source_memory.is_symlink() + + memory_files = list(_iter_regular_files(source_memory)) if has_source_memory else [] + response_files: list[tuple[Path, PurePosixPath, Path]] = [] responses_skipped = 0 - - for prefix, copied_key, skipped_key in ( - (_DATA_PREFIXES[0], "memoryCopied", "memorySkipped"), - (_DATA_PREFIXES[1], "responsesCopied", "responsesSkipped"), - ): - copied = 0 - skipped = 0 - source_root = source.joinpath(*prefix.parts) - if source_root.is_dir() and not source_root.is_symlink(): - for item, relative in _iter_regular_files(source_root): - target = destination_root.joinpath(*prefix.parts, *relative.parts) - if target.exists(): - skipped += 1 - continue + if source_responses.is_dir() and not source_responses.is_symlink(): + for item, relative in _iter_regular_files(source_responses): + target = destination_responses.joinpath(*relative.parts) + if target.exists() or target.is_symlink(): + responses_skipped += 1 + continue + _assert_safe_new_target(destination_root, target) + response_files.append((item, relative, target)) + + source_session_items, sessions_found, sessions_skipped = _read_source_sessions(source) + known_session_ids = _existing_session_ids(destination_root) + if existing_session_ids is not None: + known_session_ids.update( + str(value) for value in existing_session_ids if _is_desktop_session_id(value) + ) + planned_session_ids: set[str] = set() + session_files: list[tuple[str, dict, Path, object]] = [] + for item in source_session_items: + session_id = item.get("id") + if not _is_desktop_session_id(session_id): + sessions_skipped += 1 + continue + session_id = str(session_id) + target = destination_root / "temp" / "desktop_sessions" / f"{session_id}.json" + if ( + session_id in known_session_ids + or session_id in planned_session_ids + or target.exists() + or target.is_symlink() + ): + sessions_skipped += 1 + continue + _assert_safe_new_target(destination_root, target) + try: + prepared = session_preparer(item) if session_preparer is not None else item + except Exception: + sessions_skipped += 1 + continue + planned_session_ids.add(session_id) + session_files.append((session_id, item, target, prepared)) + + backup_dir = "" + prepared_sessions = [entry[3] for entry in session_files] + with tempfile.TemporaryDirectory( + prefix=".genericagent-memory-import-", + dir=destination_root, + ) as temp_dir: + staging_root = Path(temp_dir) + staged_memory = staging_root / "memory" + if has_source_memory: + if destination_memory.is_symlink() or ( + destination_memory.exists() and not destination_memory.is_dir() + ): + raise ValueError("current memory destination is not a safe directory") + if destination_memory.is_dir(): + _copy_tree_strict(destination_memory, staged_memory) + else: + staged_memory.mkdir() + for item, relative in memory_files: + target = _prepare_overlay_target(staged_memory, relative) target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(item, target) - copied += 1 - if copied_key == "memoryCopied": - memory_copied, memory_skipped = copied, skipped - else: - responses_copied, responses_skipped = copied, skipped - return { + staged_responses: list[tuple[Path, Path]] = [] + for item, relative, target in response_files: + staged = staging_root / "responses" / Path(*relative.parts) + staged.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(item, staged) + staged_responses.append((staged, target)) + + staged_sessions: list[tuple[Path, Path]] = [] + for session_id, item, target, _prepared in session_files: + staged = staging_root / "sessions" / f"{session_id}.json" + staged.parent.mkdir(parents=True, exist_ok=True) + staged.write_text( + json.dumps(item, ensure_ascii=False, default=str), + encoding="utf-8", + ) + staged_sessions.append((staged, target)) + + memory_nonempty = ( + destination_memory.is_dir() + and next(destination_memory.iterdir(), None) is not None + ) + if has_source_memory and memory_nonempty: + backup_dir = str(_create_memory_backup(destination_root, destination_memory)) + + installed_files: list[Path] = [] + previous_memory = staging_root / "previous-memory" + memory_original_moved = False + memory_activated = False + memory_existed = destination_memory.is_dir() + try: + if has_source_memory: + if memory_existed: + os.replace(destination_memory, previous_memory) + memory_original_moved = True + os.replace(staged_memory, destination_memory) + memory_activated = True + + for staged, target in (*staged_responses, *staged_sessions): + if target.exists() or target.is_symlink(): + raise OSError(f"data destination changed during import: {target}") + _assert_safe_new_target(destination_root, target) + target.parent.mkdir(parents=True, exist_ok=True) + # Staging shares the destination filesystem. A hard link gives + # POSIX/Windows create-if-absent semantics: unlike replace(), it + # cannot overwrite a response/session created concurrently. + _install_file_add_only(staged, target) + installed_files.append(target) + except Exception as error: + rollback_errors: list[str] = [] + for target in reversed(installed_files): + try: + target.unlink() + except OSError as rollback_error: + rollback_errors.append(str(rollback_error)) + if memory_activated or memory_original_moved: + try: + if destination_memory.exists() or destination_memory.is_symlink(): + _remove_path(destination_memory) + if memory_original_moved: + os.replace(previous_memory, destination_memory) + except OSError as rollback_error: + rollback_errors.append(str(rollback_error)) + if rollback_errors: + raise OSError( + f"data import failed: {error}; rollback failed: " + + "; ".join(rollback_errors) + ) from error + raise + + result: dict[str, Any] = { "ok": True, - "memoryCopied": memory_copied, - "memorySkipped": memory_skipped, - "responsesCopied": responses_copied, + "memoryCopied": len(memory_files), + "memorySkipped": 0, + "responsesCopied": len(response_files), "responsesSkipped": responses_skipped, - "backupDir": "", + "sessionsAdded": len(session_files), + "sessionsSkipped": sessions_skipped, + "sessionsFileFound": sessions_found, + "backupDir": backup_dir, "sourceType": inspection["sourceType"], } + if session_preparer is not None: + result["_preparedSessions"] = prepared_sessions + return result diff --git a/frontends/desktop_bridge.py b/frontends/desktop_bridge.py index d0dc4a6c7..6855f600e 100644 --- a/frontends/desktop_bridge.py +++ b/frontends/desktop_bridge.py @@ -2233,7 +2233,7 @@ async def mykey_save_handler(request): def _import_memory_from(source_dir: str, ga_root: str) -> dict: - """Compatibility wrapper for add-only data import.""" + """Compatibility wrapper for the transactional Desktop data import.""" return merge_data_files(source_dir, ga_root) @@ -2251,8 +2251,22 @@ async def memory_import_inspect_handler(request): def _import_data_source(source_path: str) -> dict: with materialize_import_source(source_path) as source_root: - result = _import_memory_from(str(source_root), manager.ga_root) - result.update(manager.import_sessions(str(source_root))) + with manager.lock: + existing_session_ids = set(manager.sessions) + result = merge_data_files( + str(source_root), + manager.ga_root, + existing_session_ids=existing_session_ids, + session_preparer=manager._session_from_item, + ) + prepared_sessions = result.pop("_preparedSessions", []) + if prepared_sessions: + # Files are already committed atomically by merge_data_files. Adopt the + # prevalidated objects without a second persistence pass that could + # partially fail or overwrite an existing Desktop session. + with manager.lock: + for session in prepared_sessions: + manager.sessions.setdefault(session.id, session) return result @@ -2260,7 +2274,7 @@ async def memory_import_handler(request): data = await read_json(request) source_path = str(data.get("sourcePath") or data.get("sourceDir") or "").strip() if not source_path: - return json_ok({"ok": False, "error": "missing_sourcePath"}, status=400) + return json_ok({"ok": False, "error": "missing_sourceDir"}, status=400) try: result = await asyncio.to_thread(_import_data_source, source_path) except (BackupFormatError, OSError, ValueError) as error: diff --git a/frontends/tests/test_bridge_sessions.py b/frontends/tests/test_bridge_sessions.py index 21885606c..fb5545c36 100644 --- a/frontends/tests/test_bridge_sessions.py +++ b/frontends/tests/test_bridge_sessions.py @@ -171,6 +171,41 @@ def mutator(): assert isinstance(data["llm_history"], list) +class TestTransactionalDataImport: + def test_import_commits_new_session_file_and_in_memory_object_together( + self, manager: AgentManager, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + source = tmp_path / "import-source" + (source / "memory").mkdir(parents=True) + (source / "memory" / "imported.md").write_text("new", encoding="utf-8") + sessions = source / "temp" / "desktop_sessions" + sessions.mkdir(parents=True) + sessions.joinpath("sess-imported.json").write_text( + json.dumps( + { + "id": "sess-imported", + "title": "Imported", + "messages": [], + "msg_seq": 0, + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(_mod, "manager", manager) + + result = _mod._import_data_source(str(source)) + + assert result["ok"] is True + assert result["sessionsAdded"] == 1 + assert "_preparedSessions" not in result + assert manager.sessions["sess-imported"].title == "Imported" + persisted = Path(manager.ga_root) / "temp" / "desktop_sessions" / "sess-imported.json" + assert json.loads(persisted.read_text(encoding="utf-8"))["id"] == "sess-imported" + assert (Path(manager.ga_root) / "memory" / "imported.md").read_text( + encoding="utf-8" + ) == "new" + + class TestLoadSessionsCorruptFile: """Verify that one corrupt file does not prevent loading others.""" diff --git a/frontends/tests/test_bridge_utils.py b/frontends/tests/test_bridge_utils.py index b2bde8f58..e440260a6 100644 --- a/frontends/tests/test_bridge_utils.py +++ b/frontends/tests/test_bridge_utils.py @@ -4,15 +4,17 @@ Run: pytest frontends/tests/test_bridge_utils.py -v """ import ast +import json import os import sys import re from pathlib import Path +from types import SimpleNamespace # Add project root so we can import bridge helpers ROOT = Path(__file__).resolve().parent.parent.parent sys.path.insert(0, str(ROOT / "frontends")) -from data_backup import merge_data_files +from data_backup import materialize_import_source, merge_data_files # Import the functions under test (module-level helpers) import importlib.util @@ -61,7 +63,7 @@ def test_invalid_override_falls_back_instead_of_becoming_app_dir(self, tmp_path, class TestMemoryImport: - def test_current_data_wins_for_memory_and_responses(self, tmp_path): + def test_source_memory_wins_while_current_responses_remain_add_only(self, tmp_path): source = tmp_path / "source" target = tmp_path / "target" (source / "memory").mkdir(parents=True) @@ -77,14 +79,61 @@ def test_current_data_wins_for_memory_and_responses(self, tmp_path): result = merge_data_files(str(source), str(target)) - assert result["memoryCopied"] == 1 - assert result["memorySkipped"] == 1 + assert result["memoryCopied"] == 2 + assert result["memorySkipped"] == 0 assert result["responsesCopied"] == 1 assert result["responsesSkipped"] == 1 - assert (target / "memory" / "same.md").read_text(encoding="utf-8") == "old" + assert (target / "memory" / "same.md").read_text(encoding="utf-8") == "new" assert (target / "memory" / "added.md").read_text(encoding="utf-8") == "added" assert (target / "temp" / "model_responses" / "same.json").read_text(encoding="utf-8") == "old" - assert result["backupDir"] == "" + assert (Path(result["backupDir"]) / "memory" / "same.md").read_text(encoding="utf-8") == "old" + + def test_bridge_adopts_only_sessions_committed_by_transaction(self, tmp_path): + source = tmp_path / "source" + target = tmp_path / "target" + (source / "memory").mkdir(parents=True) + (source / "memory" / "imported.md").write_text("new", encoding="utf-8") + source_sessions = source / "temp" / "desktop_sessions" + source_sessions.mkdir(parents=True) + source_sessions.joinpath("sess-imported.json").write_text( + json.dumps({"id": "sess-imported", "title": "Imported", "messages": []}), + encoding="utf-8", + ) + target.mkdir() + + class Lock: + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + class Manager: + ga_root = str(target) + lock = Lock() + sessions = {} + + @staticmethod + def _session_from_item(item): + return SimpleNamespace(id=item["id"], title=item.get("title", "")) + + helpers = _load_named_helpers( + {"_import_data_source"}, + { + "manager": Manager(), + "materialize_import_source": materialize_import_source, + "merge_data_files": merge_data_files, + }, + ) + + result = helpers["_import_data_source"](str(source)) + + manager = helpers["manager"] + assert result["sessionsAdded"] == 1 + assert "_preparedSessions" not in result + assert manager.sessions["sess-imported"].title == "Imported" + persisted = target / "temp" / "desktop_sessions" / "sess-imported.json" + assert json.loads(persisted.read_text(encoding="utf-8"))["id"] == "sess-imported" def _load_empty_turn_helpers(): @@ -293,8 +342,6 @@ def test_openai_alias(self): # === _format_py_dict === -import json - def _format_py_dict(d): lines = [f" '{k}': {json.dumps(v, ensure_ascii=False)}," if isinstance(v, str) else f" '{k}': {v}," for k, v in d.items()] return "{\n" + "\n".join(lines) + "\n}" diff --git a/frontends/tests/test_cost_tracker_ledger.py b/frontends/tests/test_cost_tracker_ledger.py index b2012bd3b..36b059adf 100644 --- a/frontends/tests/test_cost_tracker_ledger.py +++ b/frontends/tests/test_cost_tracker_ledger.py @@ -38,6 +38,93 @@ def teardown_function() -> None: _close_ledger() +def test_uninitialized_append_returns_before_clock_json_and_lock(monkeypatch): + class ForbiddenLock: + def __enter__(self): + raise AssertionError("uninitialized TUI path acquired the ledger lock") + + def __exit__(self, *_args): + return False + + monkeypatch.setattr( + cost_tracker.json, + "dumps", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("uninitialized TUI path serialized JSON") + ), + ) + monkeypatch.setattr( + cost_tracker.time, + "time", + lambda: (_ for _ in ()).throw( + AssertionError("uninitialized TUI path read the clock") + ), + ) + monkeypatch.setattr(cost_tracker, "_ledger_lock", ForbiddenLock()) + + cost_tracker._append_ledger("tui-main", 1, 2, 3, 4) + + +def test_public_tracker_and_context_interfaces_keep_tui_semantics(monkeypatch): + cost_tracker._trackers.clear() + tracker = cost_tracker.get("tui-main") + assert tracker is cost_tracker.get("tui-main") + tracker.requests = 2 + tracker.input = 10 + tracker.output = 4 + tracker.cache_create = 3 + tracker.cache_read = 5 + tracker.started_at = 100.0 + monkeypatch.setattr(cost_tracker.time, "time", lambda: 106.5) + + assert tracker.total_input_side() == 18 + assert tracker.total_tokens() == 22 + assert tracker.cache_hit_rate() == pytest.approx(5 / 18 * 100) + assert tracker.elapsed_seconds() == 6.5 + snapshot = cost_tracker.all_trackers() + assert snapshot == {"tui-main": tracker} + snapshot.clear() + assert cost_tracker.all_trackers() == {"tui-main": tracker} + + backend = types.SimpleNamespace( + context_win="200", + history=[{"role": "user", "content": "你好"}], + ) + assert cost_tracker.context_window_chars(backend) == 600 + assert cost_tracker.current_input_chars(backend) == len( + json.dumps(backend.history[0], ensure_ascii=False) + ) + assert cost_tracker.context_window_chars(types.SimpleNamespace(context_win="bad")) == 0 + + +def test_install_remains_idempotent_without_desktop_ledger(monkeypatch): + original_calls = [] + printed = [] + fake_llmcore = types.ModuleType("llmcore") + fake_llmcore._record_usage = lambda value, mode: original_calls.append((value, mode)) + monkeypatch.setitem(sys.modules, "llmcore", fake_llmcore) + monkeypatch.setattr(cost_tracker, "_INSTALLED", False) + monkeypatch.setattr(cost_tracker, "_trackers", {}) + monkeypatch.setattr("builtins.print", lambda *args, **_kwargs: printed.append(args)) + + cost_tracker.install() + wrapped_record = fake_llmcore._record_usage + wrapped_print = fake_llmcore.print + cost_tracker.install() + + assert fake_llmcore._record_usage is wrapped_record + assert fake_llmcore.print is wrapped_print + fake_llmcore._record_usage( + {"prompt_tokens": 7, "prompt_tokens_details": {"cached_tokens": 2}}, + "chat_completions", + ) + fake_llmcore.print("[Output] tokens=3") + tracker = cost_tracker.get(threading.current_thread().name) + assert (tracker.requests, tracker.input, tracker.output, tracker.cache_read) == (1, 5, 3, 2) + assert original_calls and printed == [("[Output] tokens=3",)] + assert cost_tracker._ledger_path is None + + def test_append_persists_and_aggregates_per_session(tmp_path): cost_tracker.init_ledger(str(tmp_path)) diff --git a/frontends/tests/test_data_backup.py b/frontends/tests/test_data_backup.py index dc27e1cbf..89a3dd8e4 100644 --- a/frontends/tests/test_data_backup.py +++ b/frontends/tests/test_data_backup.py @@ -7,6 +7,7 @@ import pytest +import frontends.data_backup as data_backup from frontends.data_backup import ( BACKUP_FORMAT_VERSION, BACKUP_SCHEMA, @@ -25,7 +26,10 @@ def _seed_data(root: Path) -> None: (root / "temp" / "model_responses").mkdir(parents=True) (root / "temp" / "model_responses" / "response.json").write_text("{}", encoding="utf-8") (root / "temp" / "desktop_sessions").mkdir(parents=True) - (root / "temp" / "desktop_sessions" / "sess-one.json").write_text("{}", encoding="utf-8") + (root / "temp" / "desktop_sessions" / "sess-one.json").write_text( + json.dumps({"id": "sess-one", "title": "One", "messages": []}), + encoding="utf-8", + ) (root / "mykey.py").write_text("secret", encoding="utf-8") (root / "agentmain.py").write_text("code", encoding="utf-8") (root / "logs").mkdir() @@ -156,19 +160,17 @@ def test_rejects_duplicate_paths_even_when_case_differs(self, tmp_path: Path): with pytest.raises(BackupFormatError, match="duplicate"): inspect_import_source(str(destination)) - def test_accepts_legacy_session_only_folder(self, tmp_path: Path): + def test_rejects_session_only_folder_as_non_ga_source(self, tmp_path: Path): source = tmp_path / "legacy" (source / "temp").mkdir(parents=True) (source / "temp" / "desktop_sessions.json").write_text("[]", encoding="utf-8") - result = inspect_import_source(str(source)) - - assert result["sourceType"] == "legacyFolder" - assert result["content"]["sessions"] == 1 + with pytest.raises(BackupFormatError, match="not a GA directory"): + inspect_import_source(str(source)) class TestDataBackupImport: - def test_materializes_backup_and_merges_without_overwrite(self, tmp_path: Path): + def test_materializes_backup_and_applies_the_full_merge_contract(self, tmp_path: Path): source = tmp_path / "source" source.mkdir() _seed_data(source) @@ -182,8 +184,254 @@ def test_materializes_backup_and_merges_without_overwrite(self, tmp_path: Path): result = merge_data_files(str(extracted), str(target)) assert (extracted / "temp" / "desktop_sessions" / "sess-one.json").is_file() - assert result["memoryCopied"] == 1 - assert result["memorySkipped"] == 1 + assert result.keys() >= { + "ok", + "memoryCopied", + "responsesCopied", + "responsesSkipped", + "sessionsAdded", + "sessionsSkipped", + "sessionsFileFound", + "backupDir", + } + assert result["memoryCopied"] == 2 + assert result["memorySkipped"] == 0 assert result["responsesCopied"] == 1 - assert (target / "memory" / "notes.md").read_text(encoding="utf-8") == "current" + assert result["sessionsAdded"] == 1 + assert result["sessionsSkipped"] == 0 + assert result["sessionsFileFound"] is True + assert (target / "memory" / "notes.md").read_text(encoding="utf-8") == "memory" assert (target / "memory" / "nested" / "facts.json").is_file() + assert (target / "temp" / "desktop_sessions" / "sess-one.json").is_file() + backup_dir = Path(result["backupDir"]) + assert backup_dir.is_dir() + assert (backup_dir / "memory" / "notes.md").read_text(encoding="utf-8") == "current" + + def test_memory_is_source_wins_responses_are_add_only(self, tmp_path: Path): + source = tmp_path / "source" + target = tmp_path / "target" + (source / "memory").mkdir(parents=True) + (source / "memory" / "same.md").write_text("new", encoding="utf-8") + (source / "memory" / "added.md").write_text("added", encoding="utf-8") + (source / "temp" / "model_responses").mkdir(parents=True) + (source / "temp" / "model_responses" / "same.json").write_text("new", encoding="utf-8") + (source / "temp" / "model_responses" / "added.json").write_text("added", encoding="utf-8") + (target / "memory").mkdir(parents=True) + (target / "memory" / "same.md").write_text("old", encoding="utf-8") + (target / "temp" / "model_responses").mkdir(parents=True) + (target / "temp" / "model_responses" / "same.json").write_text("old", encoding="utf-8") + + result = merge_data_files(str(source), str(target)) + + assert result["memoryCopied"] == 2 + assert result["responsesCopied"] == 1 + assert result["responsesSkipped"] == 1 + assert (target / "memory" / "same.md").read_text(encoding="utf-8") == "new" + assert (target / "memory" / "added.md").read_text(encoding="utf-8") == "added" + assert (target / "temp" / "model_responses" / "same.json").read_text(encoding="utf-8") == "old" + assert (target / "temp" / "model_responses" / "added.json").read_text(encoding="utf-8") == "added" + assert (Path(result["backupDir"]) / "memory" / "same.md").read_text(encoding="utf-8") == "old" + + def test_sessions_dedupe_by_desktop_id_across_new_and_legacy_stores(self, tmp_path: Path): + source = tmp_path / "source" + target = tmp_path / "target" + (source / "memory").mkdir(parents=True) + source_sessions = source / "temp" / "desktop_sessions" + source_sessions.mkdir(parents=True) + (source_sessions / "existing.json").write_text( + json.dumps({"id": "sess-existing", "messages": []}), encoding="utf-8" + ) + (source_sessions / "new.json").write_text( + json.dumps({"id": "sess-new", "messages": []}), encoding="utf-8" + ) + (source_sessions / "tui.json").write_text( + json.dumps({"id": "tui_worker", "messages": []}), encoding="utf-8" + ) + (source_sessions / "corrupt.json").write_text("{bad", encoding="utf-8") + (source / "temp" / "desktop_sessions.json").write_text( + json.dumps([ + {"id": "sess-new", "messages": []}, + {"id": "sess-legacy", "messages": []}, + {"id": "../../escape", "messages": []}, + ]), + encoding="utf-8", + ) + target_sessions = target / "temp" / "desktop_sessions" + target_sessions.mkdir(parents=True) + (target_sessions / "different-name.json").write_text( + json.dumps({"id": "sess-existing", "messages": []}), encoding="utf-8" + ) + + result = merge_data_files( + str(source), str(target), existing_session_ids={"sess-in-memory"} + ) + + assert result["sessionsAdded"] == 2 + assert result["sessionsSkipped"] == 5 + assert result["sessionsFileFound"] is True + assert (target_sessions / "sess-new.json").is_file() + assert (target_sessions / "sess-legacy.json").is_file() + assert not (tmp_path / "escape.json").exists() + + def test_empty_target_needs_no_backup_and_reports_missing_sessions(self, tmp_path: Path): + source = tmp_path / "source" + target = tmp_path / "target" + (source / "memory").mkdir(parents=True) + (source / "memory" / "one.md").write_text("one", encoding="utf-8") + target.mkdir() + + result = merge_data_files(str(source), str(target)) + + assert result["backupDir"] == "" + assert result["sessionsAdded"] == 0 + assert result["sessionsSkipped"] == 0 + assert result["sessionsFileFound"] is False + + def test_backup_failure_never_writes_the_destination( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + source = tmp_path / "source" + target = tmp_path / "target" + (source / "memory").mkdir(parents=True) + (source / "memory" / "same.md").write_text("new", encoding="utf-8") + (source / "temp" / "model_responses").mkdir(parents=True) + (source / "temp" / "model_responses" / "added.json").write_text("new", encoding="utf-8") + (target / "memory").mkdir(parents=True) + (target / "memory" / "same.md").write_text("old", encoding="utf-8") + + def fail_backup(*_args, **_kwargs): + raise OSError("backup disk full") + + monkeypatch.setattr(data_backup, "_create_memory_backup", fail_backup) + + with pytest.raises(OSError, match="backup disk full"): + merge_data_files(str(source), str(target)) + + assert (target / "memory" / "same.md").read_text(encoding="utf-8") == "old" + assert not (target / "temp" / "model_responses" / "added.json").exists() + + def test_staging_copy_failure_cleans_up_without_partial_writes( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + source = tmp_path / "source" + target = tmp_path / "target" + responses = source / "temp" / "model_responses" + responses.mkdir(parents=True) + (responses / "a-ok.json").write_text("ok", encoding="utf-8") + (responses / "z-fail.json").write_text("fail", encoding="utf-8") + target.mkdir() + real_copy2 = data_backup.shutil.copy2 + + def fail_second_copy(source_path, destination_path, *args, **kwargs): + if Path(source_path).name == "z-fail.json": + raise OSError("copy failed") + return real_copy2(source_path, destination_path, *args, **kwargs) + + monkeypatch.setattr(data_backup.shutil, "copy2", fail_second_copy) + + with pytest.raises(OSError, match="copy failed"): + merge_data_files(str(source), str(target)) + + assert not (target / "temp" / "model_responses" / "a-ok.json").exists() + assert not list(target.glob(".genericagent-memory-import-*")) + + def test_activation_partial_failure_rolls_memory_and_added_files_back( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + source = tmp_path / "source" + target = tmp_path / "target" + (source / "memory").mkdir(parents=True) + (source / "memory" / "same.md").write_text("new", encoding="utf-8") + (source / "temp" / "model_responses").mkdir(parents=True) + (source / "temp" / "model_responses" / "fail.json").write_text("new", encoding="utf-8") + (target / "memory").mkdir(parents=True) + (target / "memory" / "same.md").write_text("old", encoding="utf-8") + failing_target = target / "temp" / "model_responses" / "fail.json" + + real_install = data_backup._install_file_add_only + + def fail_response_activation(source_path, destination_path): + if Path(destination_path) == failing_target: + raise OSError("activation failed") + return real_install(source_path, destination_path) + + monkeypatch.setattr( + data_backup, "_install_file_add_only", fail_response_activation + ) + + with pytest.raises(OSError, match="activation failed"): + merge_data_files(str(source), str(target)) + + assert (target / "memory" / "same.md").read_text(encoding="utf-8") == "old" + assert not failing_target.exists() + backups = list((target / "temp").glob("memory_import_backup_*")) + assert len(backups) == 1 + assert (backups[0] / "memory" / "same.md").read_text(encoding="utf-8") == "old" + + def test_add_only_install_falls_back_on_filesystems_without_hard_links( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + source = tmp_path / "source" + target = tmp_path / "target" + responses = source / "temp" / "model_responses" + responses.mkdir(parents=True) + (responses / "portable.json").write_text("portable", encoding="utf-8") + target.mkdir() + + def unsupported_link(*_args, **_kwargs): + raise OSError(data_backup.errno.EOPNOTSUPP, "hard links unsupported") + + monkeypatch.setattr(data_backup.os, "link", unsupported_link) + + result = merge_data_files(str(source), str(target)) + + assert result["responsesCopied"] == 1 + assert ( + target / "temp" / "model_responses" / "portable.json" + ).read_text(encoding="utf-8") == "portable" + + def test_concurrent_response_creation_is_never_overwritten( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + source = tmp_path / "source" + target = tmp_path / "target" + (source / "memory").mkdir(parents=True) + (source / "memory" / "same.md").write_text("new", encoding="utf-8") + responses = source / "temp" / "model_responses" + responses.mkdir(parents=True) + (responses / "race.json").write_text("import", encoding="utf-8") + (target / "memory").mkdir(parents=True) + (target / "memory" / "same.md").write_text("old", encoding="utf-8") + racing_target = target / "temp" / "model_responses" / "race.json" + real_link = data_backup.os.link + + def collide_then_link(source_path, destination_path, **kwargs): + destination = Path(destination_path) + if destination == racing_target: + destination.write_text("concurrent", encoding="utf-8") + return real_link(source_path, destination_path, **kwargs) + + monkeypatch.setattr(data_backup.os, "link", collide_then_link) + + with pytest.raises(FileExistsError): + merge_data_files(str(source), str(target)) + + assert racing_target.read_text(encoding="utf-8") == "concurrent" + assert (target / "memory" / "same.md").read_text(encoding="utf-8") == "old" + + def test_memory_backup_refuses_symlinked_temp_directory(self, tmp_path: Path): + source = tmp_path / "source" + target = tmp_path / "target" + outside = tmp_path / "outside" + (source / "memory").mkdir(parents=True) + (source / "memory" / "same.md").write_text("new", encoding="utf-8") + (target / "memory").mkdir(parents=True) + (target / "memory" / "same.md").write_text("old", encoding="utf-8") + outside.mkdir() + (target / "temp").symlink_to(outside, target_is_directory=True) + + with pytest.raises(ValueError, match="backup destination"): + merge_data_files(str(source), str(target)) + + assert (target / "memory" / "same.md").read_text(encoding="utf-8") == "old" + assert list(outside.iterdir()) == [] From 81ca751eafc9f9c4a87958d0141e5d8d9d32a97d Mon Sep 17 00:00:00 2001 From: abraxas914 Date: Sat, 22 Aug 2026 23:18:06 +0800 Subject: [PATCH 3/5] fix(desktop): refresh packaged runtime safely --- frontends/desktop/src-tauri/build.rs | 36 +- .../src-tauri/capabilities/default.json | 25 +- .../desktop/src-tauri/capabilities/setup.json | 16 + frontends/desktop/src-tauri/src/lib.rs | 499 ++++++++++++++++-- frontends/desktop/src-tauri/tauri.conf.json | 33 +- .../desktop/src-tauri/tauri.e2e.conf.json | 4 + .../tests/test_tauri_security_contract.py | 125 +++++ 7 files changed, 676 insertions(+), 62 deletions(-) create mode 100644 frontends/desktop/src-tauri/capabilities/setup.json create mode 100644 frontends/tests/test_tauri_security_contract.py diff --git a/frontends/desktop/src-tauri/build.rs b/frontends/desktop/src-tauri/build.rs index 97ab5de72..2b5d70c5c 100644 --- a/frontends/desktop/src-tauri/build.rs +++ b/frontends/desktop/src-tauri/build.rs @@ -1,25 +1,39 @@ use std::process::Command; +fn git_text(args: &[&str]) -> Option { + Command::new("git") + .args(args) + .output() + .ok() + .filter(|output| output.status.success()) + .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string()) + .filter(|value| !value.is_empty()) +} + fn main() { // Build identity used to decide whether a bridge already holding :14168 belongs to THIS // build. commit hash + build timestamp → distinct on every build, even when the human // version in tauri.conf.json is unchanged (so same-version re-publishes still take over // a stale bridge). The bridge reports this back via GET /services/identity. - let commit = Command::new("git") - .args(["rev-parse", "--short", "HEAD"]) - .output() - .ok() - .filter(|o| o.status.success()) - .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| "nogit".to_string()); + let source_revision = git_text(&["rev-parse", "HEAD"]).unwrap_or_else(|| "nogit".to_string()); + let short_revision: String = source_revision.chars().take(12).collect(); let stamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); - println!("cargo:rustc-env=GA_BUILD_ID={}-{}", commit, stamp); - // Re-run when the checked-out commit changes so the id stays fresh. - println!("cargo:rerun-if-changed=../../../.git/HEAD"); + println!("cargo:rustc-env=GA_BUILD_ID={}-{}", short_revision, stamp); + println!("cargo:rustc-env=GA_SOURCE_REVISION={source_revision}"); + // Watch both HEAD and its resolved branch ref. This works in ordinary + // checkouts, detached CI checkouts, and linked worktrees; watching only + // `/.git/HEAD` misses branch advances and is invalid in a worktree. + if let Some(head_path) = git_text(&["rev-parse", "--git-path", "HEAD"]) { + println!("cargo:rerun-if-changed={head_path}"); + } + if let Some(head_ref) = git_text(&["symbolic-ref", "-q", "HEAD"]) { + if let Some(ref_path) = git_text(&["rev-parse", "--git-path", &head_ref]) { + println!("cargo:rerun-if-changed={ref_path}"); + } + } tauri_build::build() } diff --git a/frontends/desktop/src-tauri/capabilities/default.json b/frontends/desktop/src-tauri/capabilities/default.json index cf08e011c..5ae79f0d9 100644 --- a/frontends/desktop/src-tauri/capabilities/default.json +++ b/frontends/desktop/src-tauri/capabilities/default.json @@ -1,30 +1,19 @@ { "identifier": "default", - "description": "Default capabilities for all windows", - "windows": ["main", "setup"], - "remote": { - "urls": ["http://127.0.0.1:14168", "http://localhost:14168"] - }, + "description": "Least-privilege capabilities for the main application window", + "windows": ["main"], "permissions": [ - "core:default", - "core:window:default", - "core:window:allow-show", - "core:window:allow-hide", - "core:window:allow-set-focus", + "core:event:allow-listen", + "core:event:allow-unlisten", "core:window:allow-minimize", "core:window:allow-toggle-maximize", "core:window:allow-close", - "core:window:allow-get-all-windows", - "core:webview:default", + "core:window:allow-start-dragging", "allow-start-bridge", - "allow-start-bridge-with-config", "allow-retry-bootstrap", "allow-get-bootstrap-snapshot", - "allow-get-config", - "allow-discover-python-for-project", "allow-export-mykey", "allow-pick-directory", - "allow-pick-python-interpreter", "allow-pick-data-backup-file", "allow-pick-data-export-path", "allow-reveal-in-file-manager", @@ -34,7 +23,7 @@ "allow-clear-ga-source", "allow-shortcut-should-ask", "allow-shortcut-decide", - "core:window:allow-start-dragging", - "opener:default" + "opener:allow-open-url", + "opener:allow-default-urls" ] } diff --git a/frontends/desktop/src-tauri/capabilities/setup.json b/frontends/desktop/src-tauri/capabilities/setup.json new file mode 100644 index 000000000..4e94ab66b --- /dev/null +++ b/frontends/desktop/src-tauri/capabilities/setup.json @@ -0,0 +1,16 @@ +{ + "identifier": "setup", + "description": "Startup-recovery commands for the setup window only", + "windows": ["setup"], + "permissions": [ + "core:event:allow-listen", + "core:event:allow-unlisten", + "allow-start-bridge-with-config", + "allow-retry-bootstrap", + "allow-get-bootstrap-snapshot", + "allow-get-config", + "allow-discover-python-for-project", + "allow-pick-directory", + "allow-pick-python-interpreter" + ] +} diff --git a/frontends/desktop/src-tauri/src/lib.rs b/frontends/desktop/src-tauri/src/lib.rs index ea2def975..a77b838af 100644 --- a/frontends/desktop/src-tauri/src/lib.rs +++ b/frontends/desktop/src-tauri/src/lib.rs @@ -868,30 +868,251 @@ fn restore_setting(key: &str, value: Option) { } } +const RUNTIME_MARKER_FILENAME: &str = ".ga-package-runtime.json"; +const RUNTIME_MARKER_SCHEMA: u32 = 1; +const PRESERVED_RUNTIME_PATHS: [&str; 4] = ["mykey.py", "mykey.json", "memory", "temp"]; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct RuntimePackageMarker { + schema: u32, + package: String, + package_version: String, + build_id: String, + source_revision: String, +} + +#[derive(Debug, Eq, PartialEq)] +enum RuntimeCopyStatus { + Current, + Installed, + Refreshed, +} + +fn expected_runtime_marker() -> RuntimePackageMarker { + RuntimePackageMarker { + schema: RUNTIME_MARKER_SCHEMA, + package: env!("CARGO_PKG_NAME").to_string(), + package_version: env!("CARGO_PKG_VERSION").to_string(), + build_id: env!("GA_BUILD_ID").to_string(), + source_revision: env!("GA_SOURCE_REVISION").to_string(), + } +} + +fn runtime_marker_path(runtime: &Path) -> PathBuf { + runtime.join(RUNTIME_MARKER_FILENAME) +} + +fn read_runtime_marker(runtime: &Path) -> Option { + let text = std::fs::read_to_string(runtime_marker_path(runtime)).ok()?; + serde_json::from_str(&text).ok() +} + +fn runtime_copy_is_current(runtime: &Path, expected: &RuntimePackageMarker) -> bool { + runtime.join("agentmain.py").is_file() + && runtime + .join("frontends") + .join("desktop_bridge.py") + .is_file() + && read_runtime_marker(runtime).as_ref() == Some(expected) +} + +fn remove_fs_path(path: &Path) -> Result<(), String> { + if path.is_symlink() || path.is_file() { + std::fs::remove_file(path).map_err(|error| format!("remove {:?}: {error}", path)) + } else if path.is_dir() { + std::fs::remove_dir_all(path).map_err(|error| format!("remove {:?}: {error}", path)) + } else { + Ok(()) + } +} + fn copy_dir_replace(src: &Path, dst: &Path) -> Result<(), String> { + if src.is_symlink() || !src.is_dir() { + return Err(format!("refusing to copy unsafe directory {:?}", src)); + } std::fs::create_dir_all(dst).map_err(|error| format!("create {:?}: {error}", dst))?; for entry in std::fs::read_dir(src).map_err(|error| format!("read {:?}: {error}", src))? { let entry = entry.map_err(|error| error.to_string())?; let source = entry.path(); let destination = dst.join(entry.file_name()); let file_type = entry.file_type().map_err(|error| error.to_string())?; + if file_type.is_symlink() { + return Err(format!("refusing to copy symbolic link {:?}", source)); + } if file_type.is_dir() { if destination.exists() && !destination.is_dir() { - std::fs::remove_file(&destination) - .map_err(|error| format!("remove {:?}: {error}", destination))?; + remove_fs_path(&destination)?; } copy_dir_replace(&source, &destination)?; } else if file_type.is_file() { + if destination.exists() && !destination.is_file() { + remove_fs_path(&destination)?; + } if let Some(parent) = destination.parent() { std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; } std::fs::copy(&source, &destination) .map_err(|error| format!("copy {:?} -> {:?}: {error}", source, destination))?; + } else { + return Err(format!("refusing to copy special file {:?}", source)); + } + } + Ok(()) +} + +fn copy_preserved_runtime_data(existing: &Path, staging: &Path) -> Result<(), String> { + for relative in PRESERVED_RUNTIME_PATHS { + let source = existing.join(relative); + if !source.exists() && !source.is_symlink() { + continue; + } + if source.is_symlink() { + return Err(format!( + "refusing to preserve symbolic-link user data {:?}", + source + )); + } + let destination = staging.join(relative); + remove_fs_path(&destination)?; + if source.is_dir() { + copy_dir_replace(&source, &destination)?; + } else if source.is_file() { + if let Some(parent) = destination.parent() { + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + std::fs::copy(&source, &destination) + .map_err(|error| format!("preserve {:?} -> {:?}: {error}", source, destination))?; + } else { + return Err(format!("refusing to preserve special file {:?}", source)); } } Ok(()) } +fn unique_runtime_sibling(parent: &Path, label: &str) -> Result { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + for suffix in 0..1000_u32 { + let candidate = parent.join(format!( + ".app-{label}-{}-{nanos}-{suffix}", + std::process::id() + )); + if !candidate.exists() && !candidate.is_symlink() { + return Ok(candidate); + } + } + Err(format!("cannot allocate runtime {label} path")) +} + +fn refresh_runtime_copy_with_activation( + source: &Path, + destination: &Path, + expected: &RuntimePackageMarker, + activate: F, +) -> Result +where + F: FnOnce(&Path, &Path) -> std::io::Result<()>, +{ + if runtime_copy_is_current(destination, expected) { + return Ok(RuntimeCopyStatus::Current); + } + if source.is_symlink() + || !source.join("agentmain.py").is_file() + || !source.join("frontends").join("desktop_bridge.py").is_file() + { + return Err(format!( + "bundled core is incomplete at {}", + display_path(source) + )); + } + if destination.is_symlink() || (destination.exists() && !destination.is_dir()) { + return Err(format!( + "writable runtime is not a safe directory at {}", + display_path(destination) + )); + } + let parent = destination + .parent() + .ok_or_else(|| "writable runtime has no parent directory".to_string())?; + std::fs::create_dir_all(parent) + .map_err(|error| format!("create writable runtime parent: {error}"))?; + let staging = unique_runtime_sibling(parent, "staging")?; + std::fs::create_dir(&staging) + .map_err(|error| format!("create writable runtime staging dir: {error}"))?; + + let prepare_result = (|| { + copy_dir_replace(source, &staging)?; + if destination.is_dir() { + copy_preserved_runtime_data(destination, &staging)?; + } + let marker = serde_json::to_string_pretty(expected) + .map_err(|error| format!("serialize runtime marker: {error}"))?; + std::fs::write(runtime_marker_path(&staging), marker + "\n") + .map_err(|error| format!("write runtime marker: {error}"))?; + if !runtime_copy_is_current(&staging, expected) { + return Err("staged writable runtime failed validation".to_string()); + } + Ok(()) + })(); + if let Err(error) = prepare_result { + let _ = remove_fs_path(&staging); + return Err(error); + } + + let existed = destination.is_dir(); + let rollback = match unique_runtime_sibling(parent, "rollback") { + Ok(path) => path, + Err(error) => { + let _ = remove_fs_path(&staging); + return Err(error); + } + }; + if existed { + if let Err(error) = std::fs::rename(destination, &rollback) { + let _ = remove_fs_path(&staging); + return Err(format!("backup previous writable runtime: {error}")); + } + } + + if let Err(error) = activate(&staging, destination) { + let _ = remove_fs_path(&staging); + if existed { + if destination.exists() || destination.is_symlink() { + let _ = remove_fs_path(destination); + } + if let Err(rollback_error) = std::fs::rename(&rollback, destination) { + return Err(format!( + "activate writable runtime: {error}; restore previous runtime from {}: {rollback_error}", + display_path(&rollback) + )); + } + } + return Err(format!("activate writable runtime: {error}")); + } + + if existed { + // The activated tree already contains copied user data. This old tree is + // retained until activation succeeds, then removed on a best-effort basis. + let _ = remove_fs_path(&rollback); + Ok(RuntimeCopyStatus::Refreshed) + } else { + Ok(RuntimeCopyStatus::Installed) + } +} + +fn refresh_runtime_copy( + source: &Path, + destination: &Path, + expected: &RuntimePackageMarker, +) -> Result { + refresh_runtime_copy_with_activation(source, destination, expected, |staging, active| { + std::fs::rename(staging, active) + }) +} + fn builtin_ga_root(project_dir: &str) -> PathBuf { #[cfg(target_os = "macos")] { @@ -914,37 +1135,10 @@ fn ensure_builtin_ga_root(project_dir: &str) -> Result<(), String> { } let source = PathBuf::from(project_dir); let destination = builtin_ga_root(project_dir); - if same_path(&source, &destination) || destination.join("agentmain.py").exists() { + if same_path(&source, &destination) { return Ok(()); } - if !source.join("agentmain.py").exists() { - return Err(format!( - "bundled core is missing at {}", - display_path(&source) - )); - } - let parent = destination - .parent() - .ok_or_else(|| "writable runtime has no parent directory".to_string())?; - std::fs::create_dir_all(parent) - .map_err(|error| format!("create writable runtime parent: {error}"))?; - let staging = parent.join(format!(".app-staging-{}", std::process::id())); - if staging.exists() { - std::fs::remove_dir_all(&staging) - .map_err(|error| format!("remove stale writable runtime staging dir: {error}"))?; - } - copy_dir_replace(&source, &staging)?; - match std::fs::rename(&staging, &destination) { - Ok(()) => Ok(()), - Err(_error) if destination.join("agentmain.py").exists() => { - let _ = std::fs::remove_dir_all(&staging); - Ok(()) - } - Err(error) => { - let _ = std::fs::remove_dir_all(&staging); - Err(format!("activate writable runtime: {error}")) - } - } + refresh_runtime_copy(&source, &destination, &expected_runtime_marker()).map(|_| ()) } fn same_path(a: &Path, b: &Path) -> bool { @@ -2545,4 +2739,247 @@ mod tests { PathBuf::from("/home/user/.ga_desktop_settings.json") ); } + + fn runtime_test_root(label: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!( + "ga-runtime-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&root).unwrap(); + root + } + + fn seed_package_runtime(root: &Path, agent_text: &str, bridge_text: &str) { + std::fs::create_dir_all(root.join("frontends")).unwrap(); + std::fs::create_dir_all(root.join("memory")).unwrap(); + std::fs::create_dir_all(root.join("temp")).unwrap(); + std::fs::write(root.join("agentmain.py"), agent_text).unwrap(); + std::fs::write( + root.join("frontends").join("desktop_bridge.py"), + bridge_text, + ) + .unwrap(); + std::fs::write(root.join("memory").join("package-default.md"), "package").unwrap(); + std::fs::write(root.join("temp").join("package-cache.txt"), "package").unwrap(); + std::fs::write(root.join("mykey_template.py"), "# template").unwrap(); + } + + fn write_runtime_marker(root: &Path, marker: &RuntimePackageMarker) { + std::fs::write( + runtime_marker_path(root), + serde_json::to_string_pretty(marker).unwrap(), + ) + .unwrap(); + } + + fn runtime_tree_bytes(root: &Path) -> Vec<(PathBuf, Vec)> { + fn collect(root: &Path, folder: &Path, output: &mut Vec<(PathBuf, Vec)>) { + for entry in std::fs::read_dir(folder).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + collect(root, &path, output); + } else { + output.push(( + path.strip_prefix(root).unwrap().to_path_buf(), + std::fs::read(path).unwrap(), + )); + } + } + } + + let mut output = Vec::new(); + collect(root, root, &mut output); + output.sort_by(|left, right| left.0.cmp(&right.0)); + output + } + + #[test] + fn fresh_runtime_install_never_writes_the_packaged_source() { + let root = runtime_test_root("fresh-install"); + let package = root + .join("GenericAgent.app") + .join("Contents") + .join("Resources"); + let runtime = root.join("application-support").join("app"); + seed_package_runtime(&package, "packaged agent", "packaged bridge"); + let package_before = runtime_tree_bytes(&package); + let expected = expected_runtime_marker(); + + let result = refresh_runtime_copy(&package, &runtime, &expected).unwrap(); + + assert_eq!(result, RuntimeCopyStatus::Installed); + assert_eq!(runtime_tree_bytes(&package), package_before); + assert!(!runtime_marker_path(&package).exists()); + assert_eq!(read_runtime_marker(&runtime), Some(expected)); + assert_eq!( + std::fs::read_to_string(runtime.join("agentmain.py")).unwrap(), + "packaged agent" + ); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn stale_same_version_runtime_refreshes_package_code_and_preserves_user_data() { + let root = runtime_test_root("refresh"); + let package = root.join("signed-package-app"); + let runtime = root.join("application-support").join("app"); + seed_package_runtime(&package, "new agent", "new bridge"); + seed_package_runtime(&runtime, "old fork agent", "old fork bridge"); + + std::fs::write(runtime.join("mykey.py"), "user key").unwrap(); + std::fs::write(runtime.join("mykey.json"), "user json key").unwrap(); + std::fs::write(runtime.join("memory").join("user.md"), "remember me").unwrap(); + std::fs::remove_file(runtime.join("memory").join("package-default.md")).unwrap(); + let responses = runtime.join("temp").join("model_responses"); + let sessions = runtime.join("temp").join("desktop_sessions"); + std::fs::create_dir_all(&responses).unwrap(); + std::fs::create_dir_all(&sessions).unwrap(); + std::fs::write(responses.join("response.txt"), "response").unwrap(); + std::fs::write(sessions.join("sess-one.json"), "session").unwrap(); + std::fs::write( + runtime.join("temp").join("tui_v3_settings.json"), + "settings", + ) + .unwrap(); + std::fs::write(runtime.join("temp").join("token_ledger.jsonl"), "ledger").unwrap(); + let host_settings = root.join(".ga_desktop_settings.json"); + std::fs::write(&host_settings, "host settings").unwrap(); + + let expected = expected_runtime_marker(); + let mut old_marker = expected.clone(); + old_marker.build_id = "old-fork-build".to_string(); + old_marker.source_revision = "old-fork-source".to_string(); + write_runtime_marker(&runtime, &old_marker); + + let result = refresh_runtime_copy(&package, &runtime, &expected).unwrap(); + + assert_eq!(result, RuntimeCopyStatus::Refreshed); + assert_eq!( + std::fs::read_to_string(runtime.join("agentmain.py")).unwrap(), + "new agent" + ); + assert_eq!( + std::fs::read_to_string(runtime.join("frontends").join("desktop_bridge.py")).unwrap(), + "new bridge" + ); + assert_eq!( + std::fs::read_to_string(runtime.join("mykey.py")).unwrap(), + "user key" + ); + assert_eq!( + std::fs::read_to_string(runtime.join("mykey.json")).unwrap(), + "user json key" + ); + assert_eq!( + std::fs::read_to_string(runtime.join("memory").join("user.md")).unwrap(), + "remember me" + ); + assert!(!runtime.join("memory").join("package-default.md").exists()); + assert_eq!( + std::fs::read_to_string(responses.join("response.txt")).unwrap(), + "response" + ); + assert_eq!( + std::fs::read_to_string(sessions.join("sess-one.json")).unwrap(), + "session" + ); + assert_eq!( + std::fs::read_to_string(runtime.join("temp").join("tui_v3_settings.json")).unwrap(), + "settings" + ); + assert_eq!( + std::fs::read_to_string(runtime.join("temp").join("token_ledger.jsonl")).unwrap(), + "ledger" + ); + assert_eq!(read_runtime_marker(&runtime), Some(expected)); + assert_eq!( + std::fs::read_to_string(&host_settings).unwrap(), + "host settings" + ); + assert_eq!( + std::fs::read_to_string(package.join("agentmain.py")).unwrap(), + "new agent" + ); + assert!(!runtime_marker_path(&package).exists()); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn matching_runtime_marker_keeps_hot_start_copy_untouched() { + let root = runtime_test_root("current"); + let package = root.join("signed-package-app"); + let runtime = root.join("application-support").join("app"); + seed_package_runtime(&package, "new package bytes", "new package bridge"); + seed_package_runtime(&runtime, "already active bytes", "already active bridge"); + let expected = expected_runtime_marker(); + write_runtime_marker(&runtime, &expected); + + let result = refresh_runtime_copy(&package, &runtime, &expected).unwrap(); + + assert_eq!(result, RuntimeCopyStatus::Current); + assert_eq!( + std::fs::read_to_string(runtime.join("agentmain.py")).unwrap(), + "already active bytes" + ); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn failed_runtime_activation_restores_old_code_marker_and_user_data() { + let root = runtime_test_root("rollback"); + let package = root.join("signed-package-app"); + let runtime = root.join("application-support").join("app"); + seed_package_runtime(&package, "new agent", "new bridge"); + seed_package_runtime(&runtime, "old agent", "old bridge"); + std::fs::write(runtime.join("mykey.py"), "user key").unwrap(); + std::fs::write(runtime.join("memory").join("user.md"), "user memory").unwrap(); + let expected = expected_runtime_marker(); + let mut old_marker = expected.clone(); + old_marker.build_id = "old-build".to_string(); + write_runtime_marker(&runtime, &old_marker); + + let error = refresh_runtime_copy_with_activation( + &package, + &runtime, + &expected, + |_staging, _destination| { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "simulated activation failure", + )) + }, + ) + .unwrap_err(); + + assert!(error.contains("simulated activation failure")); + assert_eq!( + std::fs::read_to_string(runtime.join("agentmain.py")).unwrap(), + "old agent" + ); + assert_eq!( + std::fs::read_to_string(runtime.join("mykey.py")).unwrap(), + "user key" + ); + assert_eq!( + std::fs::read_to_string(runtime.join("memory").join("user.md")).unwrap(), + "user memory" + ); + assert_eq!(read_runtime_marker(&runtime), Some(old_marker)); + let leftovers = std::fs::read_dir(runtime.parent().unwrap()) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().starts_with(".app-")) + .count(); + assert_eq!(leftovers, 0); + assert_eq!( + std::fs::read_to_string(package.join("agentmain.py")).unwrap(), + "new agent" + ); + assert!(!runtime_marker_path(&package).exists()); + std::fs::remove_dir_all(root).unwrap(); + } } diff --git a/frontends/desktop/src-tauri/tauri.conf.json b/frontends/desktop/src-tauri/tauri.conf.json index e37e1f7ee..a2a9f0467 100644 --- a/frontends/desktop/src-tauri/tauri.conf.json +++ b/frontends/desktop/src-tauri/tauri.conf.json @@ -11,8 +11,37 @@ "app": { "withGlobalTauri": true, "security": { - "csp": null, - "capabilities": ["default"] + "csp": { + "default-src": "'self' tauri: asset:", + "script-src": "'self'", + "style-src": "'self' 'unsafe-inline'", + "font-src": "'self' data: asset: http://asset.localhost", + "img-src": "'self' data: blob: asset: http://asset.localhost http://127.0.0.1:14168 http://localhost:14168", + "media-src": "'self' data: blob: http://127.0.0.1:14168 http://localhost:14168", + "connect-src": "'self' ipc: http://ipc.localhost http://127.0.0.1:14168 http://localhost:14168 ws://127.0.0.1:14168 ws://localhost:14168 http://127.0.0.1:8900 http://localhost:8900 ws://127.0.0.1:8900 ws://localhost:8900", + "worker-src": "'self' blob:", + "object-src": "'none'", + "base-uri": "'self'", + "form-action": "'none'", + "frame-src": "'none'", + "frame-ancestors": "'none'" + }, + "devCsp": { + "default-src": "'self' tauri: asset: http://localhost:5173", + "script-src": "'self' 'unsafe-inline' 'unsafe-eval' http://localhost:5173", + "style-src": "'self' 'unsafe-inline' http://localhost:5173", + "font-src": "'self' data: asset: http://asset.localhost http://localhost:5173", + "img-src": "'self' data: blob: asset: http://asset.localhost http://localhost:5173 http://127.0.0.1:14168 http://localhost:14168", + "media-src": "'self' data: blob: http://127.0.0.1:14168 http://localhost:14168", + "connect-src": "'self' ipc: http://ipc.localhost http://localhost:5173 ws://localhost:5173 http://127.0.0.1:14168 http://localhost:14168 ws://127.0.0.1:14168 ws://localhost:14168 http://127.0.0.1:8900 http://localhost:8900 ws://127.0.0.1:8900 ws://localhost:8900", + "worker-src": "'self' blob:", + "object-src": "'none'", + "base-uri": "'self'", + "form-action": "'none'", + "frame-src": "'none'", + "frame-ancestors": "'none'" + }, + "capabilities": ["default", "setup"] }, "windows": [ { diff --git a/frontends/desktop/src-tauri/tauri.e2e.conf.json b/frontends/desktop/src-tauri/tauri.e2e.conf.json index efa99192f..0df11198e 100644 --- a/frontends/desktop/src-tauri/tauri.e2e.conf.json +++ b/frontends/desktop/src-tauri/tauri.e2e.conf.json @@ -1,8 +1,12 @@ { "app": { "security": { + "csp": { + "connect-src": "'self' ipc: http://ipc.localhost http://127.0.0.1:* ws://127.0.0.1:* http://localhost:14168 ws://localhost:14168 http://127.0.0.1:8900 http://localhost:8900 ws://127.0.0.1:8900 ws://localhost:8900" + }, "capabilities": [ "default", + "setup", { "identifier": "e2e", "description": "Test-only WebdriverIO capability", diff --git a/frontends/tests/test_tauri_security_contract.py b/frontends/tests/test_tauri_security_contract.py new file mode 100644 index 000000000..2effa56ab --- /dev/null +++ b/frontends/tests/test_tauri_security_contract.py @@ -0,0 +1,125 @@ +"""Security contracts for the packaged Tauri shell.""" + +from __future__ import annotations + +import json +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent.parent.parent +TAURI_ROOT = ROOT / "frontends" / "desktop" / "src-tauri" + + +def _json(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def test_production_csp_is_local_and_keeps_required_tauri_ipc_and_services(): + config = _json(TAURI_ROOT / "tauri.conf.json") + security = config["app"]["security"] + csp = security["csp"] + + assert isinstance(csp, dict) + assert csp["object-src"] == "'none'" + assert csp["base-uri"] == "'self'" + assert csp["form-action"] == "'none'" + assert csp["frame-src"] == "'none'" + assert csp["frame-ancestors"] == "'none'" + assert "'unsafe-inline'" not in csp["script-src"] + assert "'unsafe-eval'" not in csp["script-src"] + + connect_sources = set(csp["connect-src"].split()) + assert { + "'self'", + "ipc:", + "http://ipc.localhost", + "http://127.0.0.1:14168", + "ws://127.0.0.1:14168", + "http://127.0.0.1:8900", + "ws://127.0.0.1:8900", + } <= connect_sources + assert not any("*" in source for source in connect_sources) + assert not any(source.startswith("https://") for source in connect_sources) + + +def test_global_tauri_remains_only_because_bundled_compatibility_pages_use_it(): + config = _json(TAURI_ROOT / "tauri.conf.json") + fallback = (ROOT / "frontends" / "desktop" / "public" / "fallback.html").read_text( + encoding="utf-8" + ) + + assert config["app"]["withGlobalTauri"] is True + assert "window.__TAURI__" in fallback + + +def test_main_and_setup_capabilities_are_window_scoped_without_remote_ipc_access(): + config = _json(TAURI_ROOT / "tauri.conf.json") + main = _json(TAURI_ROOT / "capabilities" / "default.json") + setup = _json(TAURI_ROOT / "capabilities" / "setup.json") + + assert config["app"]["security"]["capabilities"] == ["default", "setup"] + assert main["windows"] == ["main"] + assert setup["windows"] == ["setup"] + assert "remote" not in main + assert "remote" not in setup + + main_permissions = set(main["permissions"]) + setup_permissions = set(setup["permissions"]) + assert "core:default" not in main_permissions + assert "core:window:default" not in main_permissions + assert "core:webview:default" not in main_permissions + assert "opener:default" not in main_permissions + assert { + "core:window:allow-minimize", + "core:window:allow-toggle-maximize", + "core:window:allow-close", + "core:window:allow-start-dragging", + "opener:allow-open-url", + "opener:allow-default-urls", + } <= main_permissions + + assert { + "allow-start-bridge-with-config", + "allow-retry-bootstrap", + "allow-get-bootstrap-snapshot", + "allow-get-config", + "allow-discover-python-for-project", + "allow-pick-directory", + "allow-pick-python-interpreter", + } <= setup_permissions + assert not setup_permissions.intersection( + { + "allow-export-mykey", + "allow-pick-data-export-path", + "allow-reveal-in-file-manager", + "allow-set-ga-source", + "allow-clear-ga-source", + "opener:allow-open-url", + } + ) + + +def test_e2e_config_only_adds_test_driver_and_dynamic_loopback_connectivity(): + e2e = _json(TAURI_ROOT / "tauri.e2e.conf.json") + security = e2e["app"]["security"] + capabilities = security["capabilities"] + assert capabilities[:2] == ["default", "setup"] + capability = capabilities[2] + + assert capability["identifier"] == "e2e" + assert capability["permissions"] == ["wdio:default"] + + assert set(security["csp"]) == {"connect-src"} + connect_sources = set(security["csp"]["connect-src"].split()) + assert { + "'self'", + "ipc:", + "http://ipc.localhost", + "http://127.0.0.1:*", + "ws://127.0.0.1:*", + } <= connect_sources + assert {source for source in connect_sources if "*" in source} == { + "http://127.0.0.1:*", + "ws://127.0.0.1:*", + } + assert not any(source.startswith("https://") for source in connect_sources) From 34e048340ef65ad15e8e11fb9be31b6cabda9382 Mon Sep 17 00:00:00 2001 From: abraxas914 Date: Sat, 22 Aug 2026 23:10:25 +0800 Subject: [PATCH 4/5] ci(desktop): harden release publication and package inputs --- .github/workflows/desktop-release-package.yml | 494 ++++++++---------- frontends/desktop/e2e/macos/README.md | 6 +- frontends/desktop/packaging/README.md | 94 +++- .../packaging/dmg-build-requirements.txt | 5 + .../packaging/python-runtime-requirements.txt | 37 ++ .../packaging/scripts/linux/install_linux.sh | 4 +- .../packaging/scripts/macos/install_macos.sh | 5 +- .../scripts/windows/install_windows.ps1 | 6 +- frontends/desktop/scripts/test-packaging.mjs | 121 ++++- .../desktop/scripts/verify-ci-contract.mjs | 221 +++++++- 10 files changed, 706 insertions(+), 287 deletions(-) create mode 100644 frontends/desktop/packaging/dmg-build-requirements.txt create mode 100644 frontends/desktop/packaging/python-runtime-requirements.txt diff --git a/.github/workflows/desktop-release-package.yml b/.github/workflows/desktop-release-package.yml index 0728eda80..e0f0e47b1 100644 --- a/.github/workflows/desktop-release-package.yml +++ b/.github/workflows/desktop-release-package.yml @@ -23,34 +23,46 @@ name: Build Desktop Portable Packages - "macos" permissions: - contents: write + contents: read + +env: + NODE_VERSION: "22.23.2" + RUST_TOOLCHAIN: "1.95.0" + PBS_RELEASE: "20260814" + PBS_PYTHON_VERSION: "3.12.14" jobs: # ---------------------------------------------------------------------------- build-windows: name: Windows portable - if: ${{ startsWith(github.ref, 'refs/tags/') || github.event.inputs.target == 'all' || github.event.inputs.target == 'windows' }} - runs-on: windows-latest + if: ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/desktop-portable-')) || (github.event_name == 'workflow_dispatch' && (github.event.inputs.target == 'all' || github.event.inputs.target == 'windows')) }} + runs-on: windows-2025 + permissions: + contents: read steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Set up Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: - node-version: 20 + node-version: ${{ env.NODE_VERSION }} - name: Set up Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable action snapshot + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} - name: Cache Rust build - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@63fed3e2fecf6f7b51dc6f043341b79ef82a9ae7 # v2.9.2 with: workspaces: frontends/desktop/src-tauri -> target - name: Install desktop dependencies working-directory: frontends/desktop - run: npm install + run: npm ci - name: Build Windows desktop exe working-directory: frontends/desktop @@ -58,8 +70,6 @@ jobs: - name: Assemble self-contained portable bundle shell: bash - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail @@ -77,18 +87,16 @@ jobs: cp frontends/desktop/packaging/scripts/windows/uninstall.bat "$PKG/uninstall.bat" cp frontends/desktop/packaging/scripts/windows/uninstall_windows.ps1 "$RUNTIME/uninstall_windows.ps1" - # Embedded Python (python-build-standalone, windows x86_64, 3.12 install_only). - # browser_download_url is URL-encoded ('+' -> '%2B'); match loosely with '.*'. - PBS_URL="$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" https://api.github.com/repos/astral-sh/python-build-standalone/releases/latest \ - | grep -o '"browser_download_url": *"[^"]*"' \ - | grep 'cpython-3\.12\.[0-9].*x86_64-pc-windows-msvc-install_only\.tar\.gz' \ - | head -1 | sed -E 's/.*"(https[^"]+)"/\1/')" - [[ -n "$PBS_URL" ]] || { echo "Could not resolve python-build-standalone download URL" >&2; exit 1; } + # Fixed python-build-standalone Windows x86_64 archive. + PBS_URL="https://github.com/astral-sh/python-build-standalone/releases/download/${PBS_RELEASE}/cpython-${PBS_PYTHON_VERSION}%2B${PBS_RELEASE}-x86_64-pc-windows-msvc-install_only.tar.gz" + PBS_SHA256="7330282b47cd43a66b702d39078d2e5a88e580cee351d82f95045f21f5ee042a" + PBS_ARCHIVE="${RUNNER_TEMP}/pbs-windows-x86_64.tar.gz" echo "Python build-standalone: $PBS_URL" - curl -fsSL -o /tmp/pbs.tar.gz "$PBS_URL" - tar -xzf /tmp/pbs.tar.gz -C "$RUNTIME" # extracts a 'python/' dir (python.exe at top) + curl --proto '=https' --tlsv1.2 --fail --location --retry 3 --output "$PBS_ARCHIVE" "$PBS_URL" + printf '%s %s\n' "$PBS_SHA256" "$PBS_ARCHIVE" | sha256sum --check --strict - + tar -xzf "$PBS_ARCHIVE" -C "$RUNTIME" # extracts a 'python/' dir (python.exe at top) PY="$RUNTIME/python/python.exe" - "$PY" --version + "$PY" -c 'import platform, sys; assert sys.version_info[:3] == (3, 12, 14); assert platform.machine().lower() in ("amd64", "x86_64"); print(sys.version)' # App-local UCRT: python-build-standalone links the Universal CRT dynamically # and does NOT bundle it. Win10/11 ship UCRT in-box, but stripped/older images @@ -104,11 +112,11 @@ jobs: test -f "$RUNTIME/python/api-ms-win-crt-runtime-l1-1-0.dll" \ || { echo "UCRT DLLs missing after copy" >&2; exit 1; } - # Offline wheels (native win_amd64 wheels for the embedded python) + # Offline binary-only wheels. requirements.txt pins direct and transitive versions. mkdir -p "$RUNTIME/wheels" - "$PY" -m pip download --dest "$RUNTIME/wheels" \ - "requests>=2.28" "beautifulsoup4>=4.12" "bottle>=0.12" "simple-websocket-server>=0.4" "aiohttp>=3.9" psutil \ - fastapi uvicorn websockets pydantic setuptools wheel + cp frontends/desktop/packaging/python-runtime-requirements.txt "$RUNTIME/wheels/requirements.txt" + "$PY" -m pip download --only-binary=:all: --dest "$RUNTIME/wheels" \ + --requirement "$RUNTIME/wheels/requirements.txt" # Runtime source (project root minus heavy/dev/build dirs) mkdir -p "$RUNTIME/app" @@ -116,6 +124,7 @@ jobs: --exclude='./.git' --exclude='./.github' \ --exclude='./frontends/desktop/src-tauri' \ --exclude='./frontends/desktop/node_modules' \ + --exclude='./frontends/desktop/dist' \ --exclude='./frontends/desktop/packaging' --exclude='./docs' \ --exclude='./assets/demo' --exclude='./assets/images' \ --exclude='./assets/GenericAgent_Technical_Report.pdf' \ @@ -127,6 +136,7 @@ jobs: test -f "$RUNTIME/app/agentmain.py" test -f "$RUNTIME/app/frontends/desktop_bridge.py" test -f "$RUNTIME/app/frontends/desktop/static/index.html" + test ! -e "$RUNTIME/app/frontends/desktop/dist" # Drop python debug symbols (.pdb) to slim the package (~80MB) find "$RUNTIME/python" -name '*.pdb' -delete 2>/dev/null || true @@ -192,7 +202,7 @@ jobs: Get-ChildItem artifacts/windows/out | Format-Table Name, Length - name: Upload workflow artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: GenericAgent-Desktop-Windows-Portable-${{ github.run_number }} path: | @@ -200,69 +210,31 @@ jobs: artifacts/windows/out/SHA256SUMS-windows.txt if-no-files-found: error - - name: Publish into unified Release (tag push only) - if: ${{ startsWith(github.ref, 'refs/tags/') }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - TAG_NAME="${{ github.ref_name }}" - TITLE="GenericAgent Desktop ${TAG_NAME}" - cat > "${RUNNER_TEMP:-/tmp}/ga_notes.md" <<'EOF' - GenericAgent Desktop 桌面版 / Desktop - - 无需自行安装 Python、无需联网安装依赖、无需源码;首次启动会自动准备内置运行环境。 - No separate Python install, no internet for dependencies, no source checkout required; the bundled runtime is prepared on first launch. - - ## 安装方法 / Installation - - Windows: 下载 `GenericAgent-Desktop-Windows-Portable.zip`,解压后双击 `GenericAgent.exe` 启动。卸载时先退出应用,再双击包内 `uninstall.bat`,最后删除整个解压目录。 - Download `GenericAgent-Desktop-Windows-Portable.zip`, extract it, then double-click `GenericAgent.exe`. To uninstall, quit the app, double-click `uninstall.bat`, then delete the extracted folder. - - Linux: 下载 `GenericAgent-Desktop-Linux-Portable.tar.gz`,解压后执行 `chmod +x GenericAgent.AppImage`,再运行 `./GenericAgent.AppImage`。卸载时先退出应用,再运行 `./uninstall.sh`,最后删除整个解压目录。 - Download `GenericAgent-Desktop-Linux-Portable.tar.gz`, extract it, run `chmod +x GenericAgent.AppImage`, then launch `./GenericAgent.AppImage`. To uninstall, quit the app, run `./uninstall.sh`, then delete the extracted folder. - - macOS: 下载 `GenericAgent-Desktop-macOS.dmg`,双击打开 DMG,把 `GenericAgent.app` 拖入 `Applications`,之后在“应用程序 / Applications”中启动。若出现“无法验证开发者”等提示,请右键应用选择“打开”,或在“系统设置 → 隐私与安全性”中允许打开。卸载时先退出应用,再从 Applications 删除 `GenericAgent.app`。 - Download `GenericAgent-Desktop-macOS.dmg`, open it, drag `GenericAgent.app` to `Applications`, then launch it from Applications. If macOS says the developer cannot be verified, right-click the app and choose Open, or allow it in System Settings → Privacy & Security. To uninstall, quit the app, then delete `GenericAgent.app` from Applications. - - ## 首次启动 / First launch - 首次启动会自动离线准备运行环境(安装依赖),有进度条,完成后进入主界面;之后启动会更快。 - The first launch prepares the runtime offline (installs deps) with a progress bar, then opens the main window; later launches are faster. - - ## 说明 / Notes - - 想真正对话需在程序内配置模型 / API Key。Configure a model / API key in-app to start chatting. - - Windows Defender 防火墙可能会拦截 `127.0.0.1` / `localhost` 回环连接,导致程序无法启动或连接本机服务;请允许 `GenericAgent.exe` 和随包的 `python.exe` 通过防火墙后重启。Windows Defender Firewall may block the local loopback connection; allow `GenericAgent.exe` and the bundled `python.exe` through the firewall, then restart the app. - - Windows/Linux 为便携包,可整体移动到任意目录后继续使用。Windows/Linux packages are portable and can be moved as a whole folder. - - Windows/Linux 包内附 `readme.txt`(中英)。Windows/Linux packages include a bilingual `readme.txt`. - EOF - # Create the shared release if it does not exist yet (another OS job may win the race). - # Race-safe: the 3 OS jobs share ONE release. Retry create until the release - # actually exists (a simultaneous create from another job can fail), so the - # upload below never races a not-yet-created release. - for _ in $(seq 1 15); do - gh release view "$TAG_NAME" >/dev/null 2>&1 && break - gh release create "$TAG_NAME" --target "${{ github.sha }}" --title "$TITLE" --notes-file "${RUNNER_TEMP:-/tmp}/ga_notes.md" --prerelease 2>/dev/null || true - sleep 3 - done - gh release upload "$TAG_NAME" artifacts/windows/out/* --clobber - # ---------------------------------------------------------------------------- build-linux: name: Linux portable - if: ${{ startsWith(github.ref, 'refs/tags/') || github.event.inputs.target == 'all' || github.event.inputs.target == 'linux' }} + if: ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/desktop-portable-')) || (github.event_name == 'workflow_dispatch' && (github.event.inputs.target == 'all' || github.event.inputs.target == 'linux')) }} runs-on: ubuntu-24.04 + permissions: + contents: read steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Set up Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: - node-version: 20 + node-version: ${{ env.NODE_VERSION }} - name: Set up Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable action snapshot + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} - name: Cache Rust build - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@63fed3e2fecf6f7b51dc6f043341b79ef82a9ae7 # v2.9.2 with: workspaces: frontends/desktop/src-tauri -> target @@ -278,7 +250,7 @@ jobs: - name: Install desktop dependencies working-directory: frontends/desktop - run: npm install + run: npm ci - name: Build Linux AppImage working-directory: frontends/desktop @@ -286,8 +258,6 @@ jobs: - name: Assemble self-contained portable bundle shell: bash - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail APPIMAGE_SRC="$(find frontends/desktop/src-tauri/target/release/bundle/appimage -maxdepth 1 -type f -name '*.AppImage' | head -n 1)" @@ -308,23 +278,22 @@ jobs: cp frontends/desktop/packaging/scripts/linux/uninstall.sh "$PKG/uninstall.sh" chmod +x "$PKG/uninstall.sh" - # Embedded Python (python-build-standalone, linux x86_64, 3.12 install_only). - PBS_URL="$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" https://api.github.com/repos/astral-sh/python-build-standalone/releases/latest \ - | grep -o '"browser_download_url": *"[^"]*"' \ - | grep 'cpython-3\.12\.[0-9].*x86_64-unknown-linux-gnu-install_only\.tar\.gz' \ - | head -1 | sed -E 's/.*"(https[^"]+)"/\1/')" - [[ -n "$PBS_URL" ]] || { echo "Could not resolve python-build-standalone download URL" >&2; exit 1; } + # Fixed python-build-standalone Linux x86_64 archive. + PBS_URL="https://github.com/astral-sh/python-build-standalone/releases/download/${PBS_RELEASE}/cpython-${PBS_PYTHON_VERSION}%2B${PBS_RELEASE}-x86_64-unknown-linux-gnu-install_only.tar.gz" + PBS_SHA256="3297691ae34f75fed81ac424e040145fccb0bafe8e581cd5cadbddfa1c0766c0" + PBS_ARCHIVE="${RUNNER_TEMP}/pbs-linux-x86_64.tar.gz" echo "Python build-standalone: $PBS_URL" - curl -fsSL -o /tmp/pbs.tar.gz "$PBS_URL" - tar -xzf /tmp/pbs.tar.gz -C "$RUNTIME" # extracts a 'python/' dir + curl --proto '=https' --tlsv1.2 --fail --location --retry 3 --output "$PBS_ARCHIVE" "$PBS_URL" + printf '%s %s\n' "$PBS_SHA256" "$PBS_ARCHIVE" | sha256sum --check --strict - + tar -xzf "$PBS_ARCHIVE" -C "$RUNTIME" # extracts a 'python/' dir PY="$RUNTIME/python/bin/python3" - "$PY" --version + "$PY" -c 'import platform, sys; assert sys.version_info[:3] == (3, 12, 14); assert platform.machine() == "x86_64"; print(sys.version)' - # Offline wheels (native linux wheels for the embedded python) + # Offline binary-only wheels. requirements.txt pins direct and transitive versions. mkdir -p "$RUNTIME/wheels" - "$PY" -m pip download --dest "$RUNTIME/wheels" \ - "requests>=2.28" "beautifulsoup4>=4.12" "bottle>=0.12" "simple-websocket-server>=0.4" "aiohttp>=3.9" psutil \ - fastapi uvicorn websockets pydantic setuptools wheel + cp frontends/desktop/packaging/python-runtime-requirements.txt "$RUNTIME/wheels/requirements.txt" + "$PY" -m pip download --only-binary=:all: --dest "$RUNTIME/wheels" \ + --requirement "$RUNTIME/wheels/requirements.txt" # Runtime source (project root minus heavy/dev/build dirs) mkdir -p "$RUNTIME/app" @@ -332,6 +301,7 @@ jobs: --exclude='./.git' --exclude='./.github' \ --exclude='./frontends/desktop/src-tauri' \ --exclude='./frontends/desktop/node_modules' \ + --exclude='./frontends/desktop/dist' \ --exclude='./frontends/desktop/packaging' --exclude='./docs' \ --exclude='./assets/demo' --exclude='./assets/images' \ --exclude='./assets/GenericAgent_Technical_Report.pdf' \ @@ -343,6 +313,7 @@ jobs: test -f "$RUNTIME/app/agentmain.py" test -f "$RUNTIME/app/frontends/desktop_bridge.py" test -f "$RUNTIME/app/frontends/desktop/static/index.html" + test ! -e "$RUNTIME/app/frontends/desktop/dist" # Drop python debug/cache bits to slim the package find "$RUNTIME/python" -name '__pycache__' -type d -prune -exec rm -rf {} + 2>/dev/null || true @@ -405,7 +376,7 @@ jobs: ls -lh artifacts/linux/out - name: Upload workflow artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: GenericAgent-Desktop-Linux-Portable-${{ github.run_number }} path: | @@ -413,136 +384,91 @@ jobs: artifacts/linux/out/SHA256SUMS-linux.txt if-no-files-found: error - - name: Publish into unified Release (tag push only) - if: ${{ startsWith(github.ref, 'refs/tags/') }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - TAG_NAME="${{ github.ref_name }}" - TITLE="GenericAgent Desktop ${TAG_NAME}" - cat > "${RUNNER_TEMP:-/tmp}/ga_notes.md" <<'EOF' - GenericAgent Desktop 桌面版 / Desktop - - 无需自行安装 Python、无需联网安装依赖、无需源码;首次启动会自动准备内置运行环境。 - No separate Python install, no internet for dependencies, no source checkout required; the bundled runtime is prepared on first launch. - - ## 安装方法 / Installation - - Windows: 下载 `GenericAgent-Desktop-Windows-Portable.zip`,解压后双击 `GenericAgent.exe` 启动。卸载时先退出应用,再双击包内 `uninstall.bat`,最后删除整个解压目录。 - Download `GenericAgent-Desktop-Windows-Portable.zip`, extract it, then double-click `GenericAgent.exe`. To uninstall, quit the app, double-click `uninstall.bat`, then delete the extracted folder. - - Linux: 下载 `GenericAgent-Desktop-Linux-Portable.tar.gz`,解压后执行 `chmod +x GenericAgent.AppImage`,再运行 `./GenericAgent.AppImage`。卸载时先退出应用,再运行 `./uninstall.sh`,最后删除整个解压目录。 - Download `GenericAgent-Desktop-Linux-Portable.tar.gz`, extract it, run `chmod +x GenericAgent.AppImage`, then launch `./GenericAgent.AppImage`. To uninstall, quit the app, run `./uninstall.sh`, then delete the extracted folder. - - macOS: 下载 `GenericAgent-Desktop-macOS.dmg`,双击打开 DMG,把 `GenericAgent.app` 拖入 `Applications`,之后在“应用程序 / Applications”中启动。若出现“无法验证开发者”等提示,请右键应用选择“打开”,或在“系统设置 → 隐私与安全性”中允许打开。卸载时先退出应用,再从 Applications 删除 `GenericAgent.app`。 - Download `GenericAgent-Desktop-macOS.dmg`, open it, drag `GenericAgent.app` to `Applications`, then launch it from Applications. If macOS says the developer cannot be verified, right-click the app and choose Open, or allow it in System Settings → Privacy & Security. To uninstall, quit the app, then delete `GenericAgent.app` from Applications. - - ## 首次启动 / First launch - 首次启动会自动离线准备运行环境(安装依赖),有进度条,完成后进入主界面;之后启动会更快。 - The first launch prepares the runtime offline (installs deps) with a progress bar, then opens the main window; later launches are faster. - - ## 说明 / Notes - - 想真正对话需在程序内配置模型 / API Key。Configure a model / API key in-app to start chatting. - - Windows Defender 防火墙可能会拦截 `127.0.0.1` / `localhost` 回环连接,导致程序无法启动或连接本机服务;请允许 `GenericAgent.exe` 和随包的 `python.exe` 通过防火墙后重启。Windows Defender Firewall may block the local loopback connection; allow `GenericAgent.exe` and the bundled `python.exe` through the firewall, then restart the app. - - Windows/Linux 为便携包,可整体移动到任意目录后继续使用。Windows/Linux packages are portable and can be moved as a whole folder. - - Windows/Linux 包内附 `readme.txt`(中英)。Windows/Linux packages include a bilingual `readme.txt`. - EOF - # Race-safe: the 3 OS jobs share ONE release. Retry create until the release - # actually exists (a simultaneous create from another job can fail), so the - # upload below never races a not-yet-created release. - for _ in $(seq 1 15); do - gh release view "$TAG_NAME" >/dev/null 2>&1 && break - gh release create "$TAG_NAME" --target "${{ github.sha }}" --title "$TITLE" --notes-file "${RUNNER_TEMP:-/tmp}/ga_notes.md" --prerelease 2>/dev/null || true - sleep 3 - done - gh release upload "$TAG_NAME" artifacts/linux/out/* --clobber - # ---------------------------------------------------------------------------- build-macos: - name: macOS DMG - if: ${{ startsWith(github.ref, 'refs/tags/') || github.event.inputs.target == 'all' || github.event.inputs.target == 'macos' }} - runs-on: macos-latest + name: macOS arm64 DMG + if: ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/desktop-portable-')) || (github.event_name == 'workflow_dispatch' && (github.event.inputs.target == 'all' || github.event.inputs.target == 'macos')) }} + runs-on: macos-15 + permissions: + contents: read steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Set up Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: - node-version: 20 + node-version: ${{ env.NODE_VERSION }} - name: Set up Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable action snapshot + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} - name: Cache Rust build - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@63fed3e2fecf6f7b51dc6f043341b79ef82a9ae7 # v2.9.2 with: workspaces: frontends/desktop/src-tauri -> target - name: Install desktop dependencies working-directory: frontends/desktop - run: npm install + run: npm ci + + - name: Set up Python packaging runtime + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ env.PBS_PYTHON_VERSION }} - name: Install Python packaging tools - run: python3 -m pip install --break-system-packages ds_store + run: >- + python3 -m pip install --require-hashes --only-binary=:all: + --requirement frontends/desktop/packaging/dmg-build-requirements.txt + + - name: Assert macOS runner architecture + run: test "$(uname -m)" = arm64 - name: Build macOS .app working-directory: frontends/desktop run: npm run tauri build -- --bundles app - - name: Assemble self-contained portable bundle and DMG app + - name: Assemble self-contained DMG app shell: bash - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail APP_SRC="$(find frontends/desktop/src-tauri/target/release/bundle/macos -maxdepth 1 -name '*.app' -type d | head -n 1)" [[ -n "$APP_SRC" ]] || { echo "No .app found" >&2; exit 1; } - # Build one runtime/ tree, then reuse it for both macOS deliverables: - # 1) Portable zip: GenericAgent.app + runtime/ + helper scripts. - # 2) Standard DMG: GenericAgent.app with runtime embedded in Contents/Resources/runtime. + # Build one runtime tree and embed it only in the final DMG application. RUNTIME_SRC="artifacts/macos/runtime-src" mkdir -p "$RUNTIME_SRC" cp frontends/desktop/packaging/scripts/macos/install_macos.sh "$RUNTIME_SRC/install_macos.sh" chmod +x "$RUNTIME_SRC/install_macos.sh" - # Embedded Python (python-build-standalone, macOS, 3.12 install_only; aarch64 preferred). - PBS_URL="$(python3 - <<'PY' - import json, os, urllib.request - api='https://api.github.com/repos/astral-sh/python-build-standalone/releases/latest' - req=urllib.request.Request(api) - token=os.environ.get('GH_TOKEN') - if token: - req.add_header('Authorization', 'Bearer ' + token) - data=json.load(urllib.request.urlopen(req, timeout=60)) - assets=data.get('assets', []) - for arch in ('aarch64-apple-darwin', 'x86_64-apple-darwin'): - for a in assets: - name=a.get('name','') - if 'cpython-3.12' in name and arch in name and name.endswith('install_only.tar.gz') and 'stripped' not in name: - print(a['browser_download_url']); raise SystemExit - raise SystemExit('No suitable python-build-standalone macOS asset found') - PY - )" + # Fixed python-build-standalone macOS arm64 archive. + PBS_URL="https://github.com/astral-sh/python-build-standalone/releases/download/${PBS_RELEASE}/cpython-${PBS_PYTHON_VERSION}%2B${PBS_RELEASE}-aarch64-apple-darwin-install_only.tar.gz" + PBS_SHA256="4572133a5542f306b9bdb155da5800f9e38950cd0a98d469b832ce256fe299ea" + PBS_ARCHIVE="${RUNNER_TEMP}/pbs-macos-aarch64.tar.gz" echo "Python build-standalone: $PBS_URL" - curl -L --fail --retry 3 -o /tmp/pbs-macos.tar.gz "$PBS_URL" - tar -xzf /tmp/pbs-macos.tar.gz -C "$RUNTIME_SRC" # extracts a 'python/' dir + curl --proto '=https' --tlsv1.2 --fail --location --retry 3 --output "$PBS_ARCHIVE" "$PBS_URL" + printf '%s %s\n' "$PBS_SHA256" "$PBS_ARCHIVE" | shasum -a 256 --check - + tar -xzf "$PBS_ARCHIVE" -C "$RUNTIME_SRC" # extracts a 'python/' dir PY="$RUNTIME_SRC/python/bin/python3" - "$PY" --version + "$PY" -c 'import platform, sys; assert sys.version_info[:3] == (3, 12, 14); assert platform.machine() == "arm64"; print(sys.version)' - # Offline wheels + # Offline binary-only wheels. requirements.txt pins direct and transitive versions. mkdir -p "$RUNTIME_SRC/wheels" - PYTHONDONTWRITEBYTECODE=1 "$PY" -m pip download --dest "$RUNTIME_SRC/wheels" \ - "requests>=2.28" "beautifulsoup4>=4.12" "bottle>=0.12" "simple-websocket-server>=0.4" "aiohttp>=3.9" psutil \ - fastapi uvicorn websockets pydantic setuptools wheel + cp frontends/desktop/packaging/python-runtime-requirements.txt "$RUNTIME_SRC/wheels/requirements.txt" + PYTHONDONTWRITEBYTECODE=1 "$PY" -m pip download --only-binary=:all: --dest "$RUNTIME_SRC/wheels" \ + --requirement "$RUNTIME_SRC/wheels/requirements.txt" # macOS applications must be immutable at first launch. Install from the downloaded # wheelhouse before the runtime enters the signed .app, then carry a durable marker. - # setuptools/wheel stay in the offline wheelhouse for recovery, but are build tools and - # are not needed in the prepared runtime. Bytecode writes are disabled at runtime too. + # The binary-only offline repair path needs neither setuptools nor wheel. Remove any + # copies shipped in the base interpreter; bytecode writes are disabled at runtime too. PYTHONDONTWRITEBYTECODE=1 "$PY" -m pip install --no-compile --no-index --find-links "$RUNTIME_SRC/wheels" \ - "requests>=2.28" "beautifulsoup4>=4.12" "bottle>=0.12" "simple-websocket-server>=0.4" "aiohttp>=3.9" psutil \ - fastapi uvicorn websockets pydantic + --requirement "$RUNTIME_SRC/wheels/requirements.txt" PYTHONDONTWRITEBYTECODE=1 "$PY" -m pip uninstall --yes setuptools wheel PYTHONDONTWRITEBYTECODE=1 "$PY" -m pip check @@ -572,6 +498,7 @@ jobs: --exclude='.git' --exclude='.github' \ --exclude='frontends/desktop/src-tauri' \ --exclude='frontends/desktop/node_modules' \ + --exclude='frontends/desktop/dist' \ --exclude='frontends/desktop/packaging' --exclude='docs' \ --exclude='assets/demo' --exclude='assets/images' \ --exclude='assets/GenericAgent_Technical_Report.pdf' \ @@ -583,55 +510,11 @@ jobs: test -f "$RUNTIME_SRC/app/agentmain.py" test -f "$RUNTIME_SRC/app/frontends/desktop_bridge.py" test -f "$RUNTIME_SRC/app/frontends/desktop/static/index.html" + test ! -e "$RUNTIME_SRC/app/frontends/desktop/dist" - PORTABLE="artifacts/macos/GenericAgent-Desktop-macOS-Portable" DMG_STAGE="artifacts/macos/dmg-stage" DMG_APP="$DMG_STAGE/GenericAgent.app" - mkdir -p "$PORTABLE" "$DMG_STAGE" - - # Portable zip: keep the existing release shape and helper scripts. - ditto "$APP_SRC" "$PORTABLE/GenericAgent.app" - ditto "$RUNTIME_SRC" "$PORTABLE/runtime" - test -s "$PORTABLE/runtime/.prepared" - cp frontends/desktop/packaging/scripts/macos/uninstall.command "$PORTABLE/uninstall.command" - chmod +x "$PORTABLE/uninstall.command" - cat > "$PORTABLE/Start GenericAgent.command" <<'EOF' - #!/bin/bash - DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - xattr -cr "$DIR/GenericAgent.app" 2>/dev/null || true - open "$DIR/GenericAgent.app" - EOF - chmod +x "$PORTABLE/Start GenericAgent.command" - - cat > "$PORTABLE/readme.txt" <<'EOF' - ================ 中文 ================ - GenericAgent Desktop — macOS 便携版(自包含) - - 无需安装 Python、无需联网装依赖、无需源码仓库——全部已内置。 - - 使用 - 推荐:双击 “Start GenericAgent.command” 启动。也可直接双击 GenericAgent.app。 - 请保持目录结构不变:GenericAgent.app 与 runtime/ 必须在同一目录下。 - - 卸载 - 双击 uninstall.command,或退出程序后删除整个便携文件夹。 - - ================ English ================ - GenericAgent Desktop — macOS Portable (self-contained) - - No Python install, no internet for dependencies, no source checkout — everything is bundled. - - Usage - Recommended: double-click "Start GenericAgent.command". You can also double-click GenericAgent.app. - Keep the folder layout intact: GenericAgent.app and runtime/ must stay in the same directory. - - Uninstall - Double-click uninstall.command, or quit the app and delete the whole portable folder. - EOF - sed -i '' 's/^ //' "$PORTABLE/readme.txt" - - codesign --force --deep --sign - "$PORTABLE/GenericAgent.app" - codesign --verify --deep --strict "$PORTABLE/GenericAgent.app" || true + mkdir -p "$DMG_STAGE" # Standard DMG: GenericAgent.app + Applications alias. The post-build # repackager below writes the curated two-icon Finder layout. @@ -651,8 +534,9 @@ jobs: echo "Python bytecode cache remains in final DMG app" >&2 exit 1 fi + # Ad-hoc signing only: this is not Developer ID signing or notarization. codesign --force --deep --sign - "$DMG_APP" - codesign --verify --deep --strict "$DMG_APP" || true + codesign --verify --deep --strict "$DMG_APP" ln -s /Applications "$DMG_STAGE/Applications" mkdir -p artifacts/macos/out @@ -661,67 +545,149 @@ jobs: -srcfolder "$DMG_STAGE" \ -ov \ -format UDZO \ - "artifacts/macos/out/GenericAgent-Desktop-macOS.dmg" + "artifacts/macos/out/GenericAgent-Desktop-macOS-aarch64.dmg" # Restore the intentionally minimal Finder presentation: only the app # and Applications shortcut, with the established window/icon layout. - bash frontends/desktop/scripts/post-dmg.sh "artifacts/macos/out/GenericAgent-Desktop-macOS.dmg" + bash frontends/desktop/scripts/post-dmg.sh "artifacts/macos/out/GenericAgent-Desktop-macOS-aarch64.dmg" ( cd artifacts/macos/out - shasum -a 256 "GenericAgent-Desktop-macOS.dmg" \ - > "GenericAgent-Desktop-macOS.dmg.sha256" + shasum -a 256 "GenericAgent-Desktop-macOS-aarch64.dmg" \ + > "GenericAgent-Desktop-macOS-aarch64.dmg.sha256" ) echo "DMG stage tree:" find "$DMG_STAGE" -maxdepth 2 -print | sort - name: Upload workflow artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: GenericAgent-Desktop-macOS-DMG-${{ github.run_number }} - path: artifacts/macos/out/* + name: GenericAgent-Desktop-macOS-aarch64-DMG-${{ github.run_number }} + path: | + artifacts/macos/out/GenericAgent-Desktop-macOS-aarch64.dmg + artifacts/macos/out/GenericAgent-Desktop-macOS-aarch64.dmg.sha256 if-no-files-found: error - - name: Publish into unified Release (tag push only) - if: ${{ startsWith(github.ref, 'refs/tags/') }} + # ---------------------------------------------------------------------------- + publish-release: + name: Publish one atomic prerelease + needs: [build-windows, build-linux, build-macos] + if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/desktop-portable-') && needs.build-windows.result == 'success' && needs.build-linux.result == 'success' && needs.build-macos.result == 'success' }} + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - name: Download Windows candidate + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: GenericAgent-Desktop-Windows-Portable-${{ github.run_number }} + path: ${{ runner.temp }}/release-assets + + - name: Download Linux candidate + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: GenericAgent-Desktop-Linux-Portable-${{ github.run_number }} + path: ${{ runner.temp }}/release-assets + + - name: Download macOS arm64 candidate + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: GenericAgent-Desktop-macOS-aarch64-DMG-${{ github.run_number }} + path: ${{ runner.temp }}/release-assets + + - name: Validate six assets and publish prerelease env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash run: | set -euo pipefail + + ASSET_DIR="${RUNNER_TEMP}/release-assets" + EXPECTED=( + GenericAgent-Desktop-Linux-Portable.tar.gz + GenericAgent-Desktop-Windows-Portable.zip + GenericAgent-Desktop-macOS-aarch64.dmg + GenericAgent-Desktop-macOS-aarch64.dmg.sha256 + SHA256SUMS-linux.txt + SHA256SUMS-windows.txt + ) + + if find "$ASSET_DIR" -mindepth 1 -maxdepth 1 ! -type f -print -quit | grep -q .; then + echo "Release staging contains a non-regular entry" >&2 + find "$ASSET_DIR" -mindepth 1 -maxdepth 1 -print >&2 + exit 1 + fi + mapfile -t ACTUAL < <(find "$ASSET_DIR" -mindepth 1 -maxdepth 1 -type f -printf '%f\n' | LC_ALL=C sort) + if ! diff -u <(printf '%s\n' "${EXPECTED[@]}") <(printf '%s\n' "${ACTUAL[@]}"); then + echo "Release staging must contain exactly the six expected files" >&2 + exit 1 + fi + for filename in "${EXPECTED[@]}"; do + test -s "$ASSET_DIR/$filename" || { echo "Missing or empty release asset: $filename" >&2; exit 1; } + done + + verify_checksum_manifest() { + local manifest="$1" + local payload="$2" + local normalized="${RUNNER_TEMP}/${manifest}.normalized" + tr -d '\r' < "$ASSET_DIR/$manifest" > "$normalized" + awk -v expected="$payload" ' + NF == 2 && $1 ~ /^[0-9a-fA-F]{64}$/ && $2 == expected { valid += 1 } + END { exit !(NR == 1 && valid == 1) } + ' "$normalized" + (cd "$ASSET_DIR" && sha256sum --check --strict "$normalized") + } + verify_checksum_manifest SHA256SUMS-windows.txt GenericAgent-Desktop-Windows-Portable.zip + verify_checksum_manifest SHA256SUMS-linux.txt GenericAgent-Desktop-Linux-Portable.tar.gz + verify_checksum_manifest GenericAgent-Desktop-macOS-aarch64.dmg.sha256 GenericAgent-Desktop-macOS-aarch64.dmg + TAG_NAME="${{ github.ref_name }}" TITLE="GenericAgent Desktop ${TAG_NAME}" - cat > "${RUNNER_TEMP:-/tmp}/ga_notes.md" <<'EOF' + NOTES_FILE="${RUNNER_TEMP}/ga-release-notes.md" + cat > "$NOTES_FILE" <<'EOF' GenericAgent Desktop 桌面版 / Desktop - 无需自行安装 Python、无需联网安装依赖、无需源码;首次启动会自动准备内置运行环境。 - No separate Python install, no internet for dependencies, no source checkout required; the bundled runtime is prepared on first launch. + 三个平台产物来自同一提交,并在公开发布前统一验证。All three platform artifacts come from one commit and are validated before publication. ## 安装方法 / Installation - - Windows: 下载 `GenericAgent-Desktop-Windows-Portable.zip`,解压后双击 `GenericAgent.exe` 启动。卸载时先退出应用,再双击包内 `uninstall.bat`,最后删除整个解压目录。 - Download `GenericAgent-Desktop-Windows-Portable.zip`, extract it, then double-click `GenericAgent.exe`. To uninstall, quit the app, double-click `uninstall.bat`, then delete the extracted folder. - - Linux: 下载 `GenericAgent-Desktop-Linux-Portable.tar.gz`,解压后执行 `chmod +x GenericAgent.AppImage`,再运行 `./GenericAgent.AppImage`。卸载时先退出应用,再运行 `./uninstall.sh`,最后删除整个解压目录。 - Download `GenericAgent-Desktop-Linux-Portable.tar.gz`, extract it, run `chmod +x GenericAgent.AppImage`, then launch `./GenericAgent.AppImage`. To uninstall, quit the app, run `./uninstall.sh`, then delete the extracted folder. - - macOS: 下载 `GenericAgent-Desktop-macOS.dmg`,双击打开 DMG,把 `GenericAgent.app` 拖入 `Applications`,之后在“应用程序 / Applications”中启动。若出现“无法验证开发者”等提示,请右键应用选择“打开”,或在“系统设置 → 隐私与安全性”中允许打开。卸载时先退出应用,再从 Applications 删除 `GenericAgent.app`。 - Download `GenericAgent-Desktop-macOS.dmg`, open it, drag `GenericAgent.app` to `Applications`, then launch it from Applications. If macOS says the developer cannot be verified, right-click the app and choose Open, or allow it in System Settings → Privacy & Security. To uninstall, quit the app, then delete `GenericAgent.app` from Applications. - - ## 首次启动 / First launch - 首次启动会自动离线准备运行环境(安装依赖),有进度条,完成后进入主界面;之后启动会更快。 - The first launch prepares the runtime offline (installs deps) with a progress bar, then opens the main window; later launches are faster. - - ## 说明 / Notes - - 想真正对话需在程序内配置模型 / API Key。Configure a model / API key in-app to start chatting. - - Windows Defender 防火墙可能会拦截 `127.0.0.1` / `localhost` 回环连接,导致程序无法启动或连接本机服务;请允许 `GenericAgent.exe` 和随包的 `python.exe` 通过防火墙后重启。Windows Defender Firewall may block the local loopback connection; allow `GenericAgent.exe` and the bundled `python.exe` through the firewall, then restart the app. - - Windows/Linux 为便携包,可整体移动到任意目录后继续使用。Windows/Linux packages are portable and can be moved as a whole folder. - - Windows/Linux 包内附 `readme.txt`(中英)。Windows/Linux packages include a bilingual `readme.txt`. + - Windows x64:下载 `GenericAgent-Desktop-Windows-Portable.zip`,解压后双击 `GenericAgent.exe`。卸载时运行 `uninstall.bat`,再删除解压目录。 + Windows x64: extract `GenericAgent-Desktop-Windows-Portable.zip`, then launch `GenericAgent.exe`. Run `uninstall.bat` before deleting the extracted directory. + - Linux x86_64:下载并解压 `GenericAgent-Desktop-Linux-Portable.tar.gz`,执行 `chmod +x GenericAgent.AppImage` 后运行它。卸载时运行 `uninstall.sh`,再删除解压目录。 + Linux x86_64: extract `GenericAgent-Desktop-Linux-Portable.tar.gz`, run `chmod +x GenericAgent.AppImage`, then launch it. Run `uninstall.sh` before deleting the extracted directory. + - macOS Apple silicon (arm64):打开 `GenericAgent-Desktop-macOS-aarch64.dmg`,把 `GenericAgent.app` 拖入 Applications。 + macOS Apple silicon (arm64): open `GenericAgent-Desktop-macOS-aarch64.dmg` and drag `GenericAgent.app` to Applications. + + ## 签名状态 / Signing status + macOS 应用仅使用 ad-hoc 签名,未使用 Apple Developer ID,也未 notarize。首次打开可能需要右键选择“打开”或在“隐私与安全性”中允许。 + The macOS app is ad-hoc signed only. It is neither Developer ID signed nor notarized; first launch may require Open from the context menu or approval in Privacy & Security. + + SHA-256 清单与各产物一同发布。SHA-256 manifests are included with the artifacts. EOF - # Race-safe: the 3 OS jobs share ONE release. Retry create until the release - # actually exists (a simultaneous create from another job can fail), so the - # upload below never races a not-yet-created release. - for _ in $(seq 1 15); do - gh release view "$TAG_NAME" >/dev/null 2>&1 && break - gh release create "$TAG_NAME" --target "${{ github.sha }}" --title "$TITLE" --notes-file "${RUNNER_TEMP:-/tmp}/ga_notes.md" --prerelease 2>/dev/null || true - sleep 3 + sed -i 's/^ //' "$NOTES_FILE" + + if gh release view "$TAG_NAME" >/dev/null 2>&1; then + echo "A release already exists for $TAG_NAME; refusing to overwrite it" >&2 + exit 1 + fi + + ASSETS=() + for filename in "${EXPECTED[@]}"; do + ASSETS+=("$ASSET_DIR/$filename") done - gh release upload "$TAG_NAME" artifacts/macos/out/* --clobber + # Keep the release invisible while all assets upload; only the final edit publishes it. + gh release create "$TAG_NAME" "${ASSETS[@]}" \ + --target "${{ github.sha }}" \ + --title "$TITLE" \ + --notes-file "$NOTES_FILE" \ + --draft \ + --prerelease + + mapfile -t REMOTE_ASSETS < <(gh release view "$TAG_NAME" --json assets --jq '.assets[].name' | LC_ALL=C sort) + diff -u <(printf '%s\n' "${EXPECTED[@]}") <(printf '%s\n' "${REMOTE_ASSETS[@]}") + test "$(gh release view "$TAG_NAME" --json isDraft --jq '.isDraft')" = true + test "$(gh release view "$TAG_NAME" --json isPrerelease --jq '.isPrerelease')" = true + + gh release edit "$TAG_NAME" --draft=false --prerelease + test "$(gh release view "$TAG_NAME" --json isDraft --jq '.isDraft')" = false + test "$(gh release view "$TAG_NAME" --json isPrerelease --jq '.isPrerelease')" = true diff --git a/frontends/desktop/e2e/macos/README.md b/frontends/desktop/e2e/macos/README.md index 8846c410d..42cad666e 100644 --- a/frontends/desktop/e2e/macos/README.md +++ b/frontends/desktop/e2e/macos/README.md @@ -6,11 +6,15 @@ binary, and later moves that exact app to a path containing spaces and Chinese c ```bash frontends/desktop/e2e/macos/Invoke-macOSUserJourney.sh \ - --artifact /path/GenericAgent-Desktop-macOS.dmg \ + --artifact /path/GenericAgent-Desktop-macOS-aarch64.dmg \ --expected-commit \ --keep-work-dir ``` +The release workflow builds this artifact on the explicit macOS 15 arm64 runner and names it +`aarch64`; it is not an Intel/universal binary. The app is ad-hoc signed only, not Developer ID +signed or notarized. + In addition to the shared chat/data/port/relocation checks, this journey hard-fails if the DMG does not contain the build-time `.prepared` marker or if any file inside the `.app` changes from first launch through restart and relocation. It verifies the package bridge remains inside the diff --git a/frontends/desktop/packaging/README.md b/frontends/desktop/packaging/README.md index 961e7319c..433e46392 100644 --- a/frontends/desktop/packaging/README.md +++ b/frontends/desktop/packaging/README.md @@ -2,7 +2,7 @@ 桌面端发布相关材料。本目录的内容**不会**整体打进发布包——CI (`.github/workflows/desktop-release-package.yml`)只从这里挑选 `scripts/` 下的 -安装/卸载脚本拷进各平台的发布产物,其余文件对构建打包过程是只读参考。 +安装/卸载脚本和两个 requirements lock;其余文件对构建打包过程是只读参考。 ## 目录结构 @@ -11,7 +11,9 @@ frontends/desktop/packaging/ ├── README.md # 本说明 ├── CHECKLIST.md # 发布前功能测试清单(测试协调用,不参与打包) ├── TODO.md # 各平台测试分工与计划(测试协调用,不参与打包) -└── scripts/ # ← 唯一被 CI 消费的内容 +├── python-runtime-requirements.txt # 三个平台的精确 Python runtime 依赖图 +├── dmg-build-requirements.txt # 仅 CI 使用、带 SHA-256 的 DMG 布局工具 +└── scripts/ # 平台安装/卸载脚本 ├── windows/ │ ├── install_windows.ps1 # 环境准备脚本 │ ├── uninstall.bat # 卸载入口(向用户确认后调用 ps1) @@ -28,12 +30,96 @@ frontends/desktop/packaging/ `desktop-release-package.yml` 在打各平台 portable 包时,把对应平台的脚本 `cp` 进发布目录(例如 Windows 包里放 `install_windows.ps1` / -`uninstall.bat` / `uninstall_windows.ps1`)。**修改安装/卸载行为只需改 -`scripts/` 下的文件,不需要动 workflow。** +`uninstall.bat` / `uninstall_windows.ps1`)。Python wheelhouse 同时带入 +`python-runtime-requirements.txt` 的副本,首次离线准备与 macOS 离线修复都必须从该 +精确版本清单安装。macOS 当前只发布 DMG;`uninstall.command` 保留为脚本材料,但不再 +复制到不会上传的 portable 临时目录。 > 说明:实际的桌面壳二进制(`GenericAgent.exe` / `.AppImage` / `.app`)由 CI > 构建生成并发布到 GitHub Release,不在本仓库内提交,也不在本目录占位。 +## Release 权限与发布流程 + +```text +desktop-portable-* tag push + ├─ Windows build (contents:read) ─┐ + ├─ Linux build (contents:read) ─┼─ Actions artifacts ─┐ + └─ macOS build (contents:read) ─┘ │ + ▼ + publish-release (contents:write) + 精确下载三个 artifact + 校验六个文件名/非空/SHA-256 + 创建不可见 draft 并一次上传 + 校验远端六个 assets + 公开为统一 prerelease +``` + +三个 build job 的 checkout 均设置 `persist-credentials: false`,不接收写 token,也不调用 +GitHub Release API。`publish-release` 显式 `needs` 三个平台,且只在 tag push、三个结果都为 +`success` 时运行;它不 checkout、不运行 npm/pip/Cargo 或仓库构建脚本。上传失败时最多留下 +不可见 draft,不会向用户暴露半套 Release;发布者也拒绝覆盖同 tag 的既有 Release。 + +`workflow_dispatch` 只运行所选平台(或 all)的候选 artifact 构建。即使未选择的 jobs 是 +`skipped`,发布 job 的事件与三个 `success` 条件也不会成立,因此手工运行不会创建 Release。 +应用 metadata 版本仍为 `0.2.0`;workflow 只消费维护者实际 push 的匹配 tag,不创建、移动或 +改写既有 tag,也不把 `V2.0.0` 等公开命名强行绑定到应用 metadata。 + +统一 prerelease 必须精确包含以下六个文件: + +- `GenericAgent-Desktop-Windows-Portable.zip` +- `SHA256SUMS-windows.txt` +- `GenericAgent-Desktop-Linux-Portable.tar.gz` +- `SHA256SUMS-linux.txt` +- `GenericAgent-Desktop-macOS-aarch64.dmg` +- `GenericAgent-Desktop-macOS-aarch64.dmg.sha256` + +## 固定输入与可复现边界 + +- npm 使用 `npm ci` 和已提交的 `package-lock.json`;Node 固定为 `22.23.2`。 +- Rust toolchain 固定为 `1.95.0`,crate 图由 `Cargo.lock` 固定。 +- `actions/checkout`、`setup-node`、`setup-python`、artifact upload/download、Rust toolchain + action 与 Rust cache action 均固定完整 commit SHA,workflow 行尾保留对应版本/来源注释。 +- python-build-standalone 固定 release `20260814`、CPython `3.12.14` 和明确架构;Windows + x86_64、Linux x86_64、macOS arm64 三个 tarball 均在解压前校验已记录的 SHA-256。 +- Python runtime 的 32 个直接/传递依赖全部在 `python-runtime-requirements.txt` 使用 `==`; + wheel 下载强制 `--only-binary=:all:`。因此离线准备不需要 `setuptools`/`wheel`,二者不再 + 放入 wheelhouse。macOS 的 `ds-store==1.3.3` 与 `mac-alias==2.2.3` 另行使用 wheel hash 锁。 +- runner label 使用 `windows-2025`、`ubuntu-24.04`、`macos-15`。GitHub 当前的 + `macos-15` 标准 runner 是 Apple silicon;workflow 仍以 `uname -m == arm64` 硬断言, + PBS 与最终 DMG 名称也明确为 `aarch64`。 + +这里不声称 bit-for-bit reproducible,仍有以下明确风险:GitHub runner label 内的 image +revision 会更新;Ubuntu apt 包与 Windows runner 提供的 UCRT SDK 版本未锁;Python runtime +依赖版本虽已固定,但三平台 wheel 文件 hash 尚未完整写入跨平台 lock;Tauri/系统打包工具 +也可能写入时间戳或平台元数据。以上变化必须由真包 SHA-256 与 L5 旅程留证,不能把当前约束 +描述成完整可复现构建。 + +## macOS 架构与签名事实 + +当前 macOS 产物只支持 Apple silicon arm64,文件名为 +`GenericAgent-Desktop-macOS-aarch64.dmg`。应用在嵌入 runtime 后只做 ad-hoc signing,且 +`codesign --verify --deep --strict` 失败会直接终止构建。当前没有 Apple Developer ID 签名, +也没有 notarization;Release notes 必须保留这一事实,不能描述为已公证或受 Apple 信任。 + +## 包体精简记录 + +下列实测基于从 commit `e09643142d445424d1d3cb779245da0e920e2339` 派生的本任务工作树, +在 2026-08-22 以 Node `22.23.2` 执行 `npm ci` + production build,并用 workflow 同等 +source excludes 对 runtime/app 源树做 gzip 对比: + +| 项目 | 结果 | 性质 | +|---|---:|---| +| 生成的 `frontends/desktop/dist` | 106 files / 4,687,796 bytes | 实测未压缩 payload | +| runtime source gzip(仍含 dist) | 4,410,077 bytes | 实测对照 | +| runtime source gzip(排除 dist) | 2,529,269 bytes | 实测新结果 | +| 单份 runtime source gzip 减少 | 1,880,808 bytes(约 1.79 MiB) | 实测;最终 ZIP/tar.gz/DMG 会因各自压缩器而不同 | + +`setuptools-84.0.0` 与 `wheel-0.48.0` 的当前 wheel 合计实测为 851,536 bytes(约 +0.81 MiB);这是对旧浮动解析在测量时点的估算,每个平台最终压缩包的实际差值会不同,不能 +当作历史产物精确差值。删除 macOS portable staging 对发布 DMG 的包体差值是 **0**(该目录 +从未上传);CI 峰值磁盘预计减少一份 `.app + runtime` 的重复副本,但未运行完整远端 macOS +job 测量,因此只记录为估算,不给出伪精确数字。 + ## 自动化测试体系 CI 会先运行零依赖的 `npm run test:ci-contract`,确认 workflow、npm 清单与锁文件、 diff --git a/frontends/desktop/packaging/dmg-build-requirements.txt b/frontends/desktop/packaging/dmg-build-requirements.txt new file mode 100644 index 000000000..677ed72bc --- /dev/null +++ b/frontends/desktop/packaging/dmg-build-requirements.txt @@ -0,0 +1,5 @@ +# Pure-Python tools used only to write the curated Finder layout. +ds-store==1.3.3 \ + --hash=sha256:b92a371efbf1b4ccce2a04d1ed13fceacc4736c81ba09cf5aefb74c088160a35 +mac-alias==2.2.3 \ + --hash=sha256:7362b521d2132ef92f606a37abfed5fcd849ceb2f28b6f9743e014b02af92f0d diff --git a/frontends/desktop/packaging/python-runtime-requirements.txt b/frontends/desktop/packaging/python-runtime-requirements.txt new file mode 100644 index 000000000..3fe5dd802 --- /dev/null +++ b/frontends/desktop/packaging/python-runtime-requirements.txt @@ -0,0 +1,37 @@ +# Desktop embedded-runtime lock for CPython 3.12.14. +# +# Direct and transitive versions are exact so all three builders resolve the same +# dependency graph. Wheel file hashes remain platform-specific and are not locked +# here; see README.md for that remaining reproducibility risk. +aiohappyeyeballs==2.7.1 +aiohttp==3.14.3 +aiosignal==1.4.0 +annotated-doc==0.0.5 +annotated-types==0.8.0 +anyio==4.14.2 +attrs==26.1.0 +beautifulsoup4==4.15.0 +bottle==0.13.4 +certifi==2026.7.22 +charset-normalizer==3.5.1 +click==8.4.2 +colorama==0.4.6 +fastapi==0.141.1 +frozenlist==1.8.0 +h11==0.16.0 +idna==3.19 +multidict==6.7.1 +propcache==0.5.2 +psutil==7.1.1 +pydantic==2.13.4 +pydantic-core==2.46.4 +requests==2.34.2 +simple-websocket-server==0.4.4 +soupsieve==2.9.2 +starlette==1.6.0 +typing-extensions==4.16.0 +typing-inspection==0.4.4 +urllib3==2.7.0 +uvicorn==0.52.1 +websockets==17.0.1 +yarl==1.24.5 diff --git a/frontends/desktop/packaging/scripts/linux/install_linux.sh b/frontends/desktop/packaging/scripts/linux/install_linux.sh index f8b14242c..efdf3c07b 100755 --- a/frontends/desktop/packaging/scripts/linux/install_linux.sh +++ b/frontends/desktop/packaging/scripts/linux/install_linux.sh @@ -199,13 +199,15 @@ install_dependencies() { if [[ -n "$WHEEL_DIR" ]]; then # Offline (portable bundle): install from local wheels only, no network, no pip self-upgrade. log_step "Install dependencies offline from $WHEEL_DIR" + local locked_requirements="$WHEEL_DIR/requirements.txt" + [[ -f "$locked_requirements" ]] || fail "Pinned offline requirements are missing: $locked_requirements" # Install deps directly (NOT an editable -e of the source): an editable install bakes the # project's absolute path into a .pth. Combined with --no-venv (deps go into the relocatable # embedded python) this keeps the portable bundle movable. The bridge adds the source to # sys.path itself (ensure_ga_import_path), so no install of the project is needed. # shellcheck disable=SC2086 "$py" -m pip install --no-index --find-links "$WHEEL_DIR" \ - "requests>=2.28" "beautifulsoup4>=4.12" "bottle>=0.12" "simple-websocket-server>=0.4" "aiohttp>=3.9" psutil $EXTRA_PACKAGES \ + --requirement "$locked_requirements" $EXTRA_PACKAGES \ || fail "Offline pip install failed (check wheel dir)." else log_step "Install/refresh minimal Python dependencies" diff --git a/frontends/desktop/packaging/scripts/macos/install_macos.sh b/frontends/desktop/packaging/scripts/macos/install_macos.sh index 39c4652ab..56169d763 100755 --- a/frontends/desktop/packaging/scripts/macos/install_macos.sh +++ b/frontends/desktop/packaging/scripts/macos/install_macos.sh @@ -119,7 +119,10 @@ install_deps() { if [[ -n "$WHEEL_DIR" && -d "$WHEEL_DIR" ]]; then # A wheelhouse repair only needs pip itself. Keep the path fully offline and # do not install setuptools/wheel into the prepared runtime. - "$py" -m pip install --no-compile --no-index --find-links "$WHEEL_DIR" "${pkgs[@]}" + local locked_requirements="$WHEEL_DIR/requirements.txt" + [[ -f "$locked_requirements" ]] || fail "Pinned offline requirements are missing: $locked_requirements" + "$py" -m pip install --no-compile --no-index --find-links "$WHEEL_DIR" \ + --requirement "$locked_requirements" "${pkgs[@]:6}" else log_warn "No wheel dir supplied; falling back to online pip install" "$py" -m pip install --upgrade pip setuptools wheel diff --git a/frontends/desktop/packaging/scripts/windows/install_windows.ps1 b/frontends/desktop/packaging/scripts/windows/install_windows.ps1 index e501d5b30..1b1035301 100644 --- a/frontends/desktop/packaging/scripts/windows/install_windows.ps1 +++ b/frontends/desktop/packaging/scripts/windows/install_windows.ps1 @@ -159,6 +159,10 @@ function Install-Dependencies([string]$root, [string]$py) { # Offline mode (portable bundle): install from local wheels only, no network, no pip self-upgrade. if ($WheelDir) { $wd = (Resolve-Path $WheelDir -ErrorAction Stop).Path + $lockedRequirements = Join-Path $wd "requirements.txt" + if (-not (Test-Path $lockedRequirements -PathType Leaf)) { + Fail "Pinned offline requirements are missing: $lockedRequirements" + } Write-Ok "offline wheels: $wd" if ($extra.Count) { Write-Ok "extra packages: $($extra -join ', ')" } Write-Host "GAPROGRESS|deps" @@ -167,7 +171,7 @@ function Install-Dependencies([string]$root, [string]$py) { # project's absolute path into a .pth. With -NoVenv (deps go into the relocatable embedded # python) this keeps the portable bundle movable. The bridge adds the source to sys.path # itself (ensure_ga_import_path), so the project itself need not be installed. - & $py -m pip install --no-index --find-links $wd "requests>=2.28" "beautifulsoup4>=4.12" "bottle>=0.12" "simple-websocket-server>=0.4" "aiohttp>=3.9" psutil @extra + & $py -m pip install --no-index --find-links $wd --requirement $lockedRequirements @extra if ($LASTEXITCODE -ne 0) { Fail "offline pip install failed (check wheels dir)." } Write-Host "GAPROGRESS|done" return diff --git a/frontends/desktop/scripts/test-packaging.mjs b/frontends/desktop/scripts/test-packaging.mjs index b7204e26b..64f487c71 100644 --- a/frontends/desktop/scripts/test-packaging.mjs +++ b/frontends/desktop/scripts/test-packaging.mjs @@ -122,6 +122,48 @@ if (!fs.existsSync(scriptsDir)) { } } + const powershellScripts = []; + const windowsScriptsDir = path.join(scriptsDir, 'windows'); + if (fs.existsSync(windowsScriptsDir)) { + for (const file of fs.readdirSync(windowsScriptsDir)) { + if (file.endsWith('.ps1')) powershellScripts.push(path.join(windowsScriptsDir, file)); + } + } + let hasPowerShell = true; + try { + execFileSync('pwsh', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$PSVersionTable.PSVersion.ToString()'], { + timeout: 5000, + stdio: 'ignore', + }); + } catch (e) { + if (e.code === 'ENOENT') { + hasPowerShell = false; + warn('pwsh is unavailable; PowerShell parser checks were skipped'); + } else { + bad(`pwsh probe failed: ${e.message}`); + } + } + if (hasPowerShell) { + for (const script of powershellScripts) { + const escapedPath = script.replaceAll("'", "''"); + const command = [ + '$tokens = $null', + '$errors = $null', + `[System.Management.Automation.Language.Parser]::ParseFile('${escapedPath}', [ref]$tokens, [ref]$errors) > $null`, + 'if ($errors.Count -ne 0) { $errors | ForEach-Object { Write-Error $_ }; exit 1 }', + ].join('; '); + try { + execFileSync('pwsh', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', command], { + timeout: 5000, + stdio: 'pipe', + }); + ok(`syntax OK: ${path.relative(DESKTOP_ROOT, script)}`); + } catch (e) { + bad(`syntax error: ${path.relative(DESKTOP_ROOT, script)}\n ${e.stderr?.toString().trim() || e.message}`); + } + } + } + for (const relative of [ 'scripts/post-dmg.sh', 'e2e/linux/Invoke-LinuxUserJourney.sh', @@ -137,21 +179,90 @@ if (!fs.existsSync(scriptsDir)) { } for (const relative of [ + 'scripts/gen_ds_store.py', 'e2e/package/real_package_journey.py', 'e2e/package/verify_candidate_evidence.py', ]) { const script = path.join(DESKTOP_ROOT, relative); try { - execFileSync('python3', [script, '--help'], { timeout: 5000, stdio: 'ignore' }); - ok(`CLI contract OK: ${relative}`); + execFileSync('python3', [ + '-c', + 'import ast, pathlib, sys; ast.parse(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"), filename=sys.argv[1])', + script, + ], { timeout: 5000, stdio: 'ignore' }); + ok(`syntax OK: ${relative}`); } catch (e) { - bad(`CLI contract failed: ${relative}: ${e.message}`); + bad(`syntax error: ${relative}: ${e.message}`); + continue; + } + if (relative.startsWith('e2e/')) { + try { + execFileSync('python3', [script, '--help'], { timeout: 5000, stdio: 'ignore' }); + ok(`CLI contract OK: ${relative}`); + } catch (e) { + bad(`CLI contract failed: ${relative}: ${e.message}`); + } } } } -// ── 4. Release version consistency ── -console.log('\n[4] Version consistency'); +// ── 4. Locked package inputs ── +console.log('\n[4] Locked package inputs'); + +const runtimeRequirementsPath = path.join(PACKAGING_DIR, 'python-runtime-requirements.txt'); +const dmgRequirementsPath = path.join(PACKAGING_DIR, 'dmg-build-requirements.txt'); +if (!fs.existsSync(runtimeRequirementsPath)) { + bad('packaging/python-runtime-requirements.txt is missing'); +} else { + const requirements = fs.readFileSync(runtimeRequirementsPath, 'utf8') + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')); + const invalid = requirements.filter((line) => !/^[a-z0-9][a-z0-9._-]*==[^\s]+$/i.test(line)); + if (requirements.length > 20 && invalid.length === 0) { + ok(`${requirements.length} runtime requirements use exact versions`); + } else { + bad(`runtime requirements must be a complete exact lock; invalid: ${invalid.join(', ') || 'too few entries'}`); + } + const names = new Set(requirements.map((line) => line.split('==', 1)[0].toLowerCase())); + for (const name of ['requests', 'beautifulsoup4', 'bottle', 'simple-websocket-server', 'aiohttp', 'psutil', 'fastapi', 'uvicorn', 'websockets', 'pydantic']) { + if (names.has(name)) ok(`runtime lock includes ${name}`); + else bad(`runtime lock is missing ${name}`); + } + if (!names.has('setuptools') && !names.has('wheel')) { + ok('binary-only runtime wheelhouse excludes setuptools and wheel'); + } else { + bad('binary-only runtime wheelhouse must exclude setuptools and wheel'); + } +} + +if (!fs.existsSync(dmgRequirementsPath)) { + bad('packaging/dmg-build-requirements.txt is missing'); +} else { + const requirements = fs.readFileSync(dmgRequirementsPath, 'utf8'); + const hashes = requirements.match(/--hash=sha256:[0-9a-f]{64}/g) ?? []; + if (requirements.includes('ds-store==1.3.3') && requirements.includes('mac-alias==2.2.3') && hashes.length === 2) { + ok('DMG build requirements use exact versions and SHA-256 hashes'); + } else { + bad('DMG build requirements are not fully pinned and hash-locked'); + } +} + +for (const relative of [ + 'scripts/windows/install_windows.ps1', + 'scripts/linux/install_linux.sh', + 'scripts/macos/install_macos.sh', +]) { + const content = fs.readFileSync(path.join(PACKAGING_DIR, relative), 'utf8'); + if (content.includes('requirements.txt') && content.includes('--requirement')) { + ok(`offline installer consumes packaged requirements lock: ${relative}`); + } else { + bad(`offline installer bypasses packaged requirements lock: ${relative}`); + } +} + +// ── 5. Release version consistency ── +console.log('\n[5] Version consistency'); const cargoPath = path.join(TAURI_DIR, 'Cargo.toml'); const cargoLockPath = path.join(TAURI_DIR, 'Cargo.lock'); diff --git a/frontends/desktop/scripts/verify-ci-contract.mjs b/frontends/desktop/scripts/verify-ci-contract.mjs index e9659d676..7f815a784 100644 --- a/frontends/desktop/scripts/verify-ci-contract.mjs +++ b/frontends/desktop/scripts/verify-ci-contract.mjs @@ -97,16 +97,204 @@ for (const workflowPath of workflowPaths) { } const releaseWorkflow = readText('.github/workflows/desktop-release-package.yml', repoRoot); -for (const job of ['build-windows', 'build-linux', 'build-macos']) { - check(new RegExp(`^ ${job}:\\s*$`, 'm').test(releaseWorkflow), `release workflow keeps upstream job: ${job}`); +function workflowJob(workflow, name) { + const header = new RegExp(`^ ${name}:\\s*$`, 'm'); + const match = header.exec(workflow); + if (!match) return ''; + const remainder = workflow.slice(match.index + match[0].length); + const nextJob = /^ [a-zA-Z0-9_-]+:\s*$/m.exec(remainder); + return nextJob ? remainder.slice(0, nextJob.index) : remainder; } -const macJobOffset = releaseWorkflow.search(/^ build-macos:\s*$/m); -const macJobWorkflow = macJobOffset >= 0 ? releaseWorkflow.slice(macJobOffset) : ''; + +const buildJobs = ['build-windows', 'build-linux', 'build-macos']; +const releaseJobsSection = releaseWorkflow.slice(releaseWorkflow.search(/^jobs:\s*$/m)); +const releaseJobNames = [...releaseJobsSection.matchAll(/^ ([a-zA-Z0-9_-]+):\s*$/gm)].map((match) => match[1]); +check( + JSON.stringify(releaseJobNames) === JSON.stringify([...buildJobs, 'publish-release']), + 'release workflow contains only three builders and one publisher', +); +const buildJobWorkflows = Object.fromEntries(buildJobs.map((job) => [job, workflowJob(releaseWorkflow, job)])); +for (const job of buildJobs) { + const workflow = buildJobWorkflows[job]; + check(Boolean(workflow), `release workflow keeps build job: ${job}`); + check( + /^ permissions:\n contents: read\s*$/m.test(workflow) + && !/^ [a-z-]+:\s+(?:write|read)\s*$/m.test(workflow.replace(' contents: read', '')), + `${job} has only contents: read permission`, + ); + check( + /uses: actions\/checkout@[0-9a-f]{40}[^\n]*\n with:\n persist-credentials: false/.test(workflow), + `${job} checkout does not persist credentials`, + ); + check( + !workflow.includes('secrets.GITHUB_TOKEN') && !/\bgh release\b/.test(workflow), + `${job} neither receives a release token nor publishes`, + ); + check( + workflow.includes('npm ci') && !workflow.includes('npm install'), + `${job} installs the locked npm graph with npm ci`, + ); + check( + workflow.includes("--exclude='./frontends/desktop/dist'") + || workflow.includes("--exclude='frontends/desktop/dist'"), + `${job} excludes the Tauri-embedded React dist from runtime/app`, + ); + check( + workflow.includes('test ! -e "$RUNTIME/app/frontends/desktop/dist"') + || workflow.includes('test ! -e "$RUNTIME_SRC/app/frontends/desktop/dist"'), + `${job} hard-fails if React dist re-enters runtime/app`, + ); + check( + workflow.includes('pip download --only-binary=:all:') + && workflow.includes('packaging/python-runtime-requirements.txt'), + `${job} downloads the exact binary-only runtime requirement set`, + ); +} + +check( + /^permissions:\n contents: read\s*$/m.test(releaseWorkflow), + 'release workflow defaults to contents: read', +); + +const pinnedActionRefs = new Set([ + 'actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd', + 'actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444', + 'actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97', + 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a', + 'actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131', + 'dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c', + 'Swatinem/rust-cache@63fed3e2fecf6f7b51dc6f043341b79ef82a9ae7', +]); +const actionLines = releaseWorkflow.split('\n').filter((line) => /\buses:\s+/.test(line)); +for (const line of actionLines) { + const match = line.match(/uses:\s+([^\s#]+)(?:\s+#\s+(.+))?$/); + check( + Boolean(match && pinnedActionRefs.has(match[1]) && match[2]), + `release action is a trusted full-SHA pin with a version/source comment: ${match?.[1] ?? line.trim()}`, + ); +} +for (const actionRef of pinnedActionRefs) { + check(releaseWorkflow.includes(actionRef), `release workflow includes pinned action: ${actionRef}`); +} + +check( + releaseWorkflow.includes('NODE_VERSION: "22.23.2"') + && releaseWorkflow.includes('RUST_TOOLCHAIN: "1.95.0"') + && releaseWorkflow.includes('PBS_RELEASE: "20260814"') + && releaseWorkflow.includes('PBS_PYTHON_VERSION: "3.12.14"') + && !releaseWorkflow.includes('/releases/latest'), + 'Node, Rust, and python-build-standalone versions are exact and do not use releases/latest', +); +for (const digest of [ + '7330282b47cd43a66b702d39078d2e5a88e580cee351d82f95045f21f5ee042a', + '3297691ae34f75fed81ac424e040145fccb0bafe8e581cd5cadbddfa1c0766c0', + '4572133a5542f306b9bdb155da5800f9e38950cd0a98d469b832ce256fe299ea', +]) { + check(releaseWorkflow.includes(digest), `release workflow pins PBS SHA-256: ${digest}`); +} + +const runtimeRequirements = readText('packaging/python-runtime-requirements.txt'); +const runtimeRequirementLines = runtimeRequirements + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')); +check( + runtimeRequirementLines.length > 20 + && runtimeRequirementLines.every((line) => /^[a-z0-9][a-z0-9._-]*==[^\s]+$/i.test(line)), + 'runtime direct and transitive requirements use exact versions', +); +for (const dependency of [ + 'requests', + 'beautifulsoup4', + 'bottle', + 'simple-websocket-server', + 'aiohttp', + 'psutil', + 'fastapi', + 'uvicorn', + 'websockets', + 'pydantic', +]) { + check( + runtimeRequirementLines.some((line) => line.startsWith(`${dependency}==`)), + `runtime lock includes direct dependency: ${dependency}`, + ); +} +check( + !runtimeRequirementLines.some((line) => /^(?:setuptools|wheel)==/i.test(line)), + 'binary-only recovery wheelhouse omits setuptools and wheel', +); + +const dmgBuildRequirements = readText('packaging/dmg-build-requirements.txt'); +check( + dmgBuildRequirements.includes('ds-store==1.3.3') + && dmgBuildRequirements.includes('mac-alias==2.2.3') + && (dmgBuildRequirements.match(/--hash=sha256:[0-9a-f]{64}/g) ?? []).length === 2, + 'DMG layout build requirements are exact and hash-locked', +); + +const publisherWorkflow = workflowJob(releaseWorkflow, 'publish-release'); +check(Boolean(publisherWorkflow), 'release workflow has one publisher job'); +check( + publisherWorkflow.includes('needs: [build-windows, build-linux, build-macos]') + && ['build-windows', 'build-linux', 'build-macos'].every((job) => publisherWorkflow.includes(`needs.${job}.result == 'success'`)) + && publisherWorkflow.includes("github.event_name == 'push'") + && publisherWorkflow.includes("refs/tags/desktop-portable-"), + 'publisher requires all three successful tag builds and cannot run for workflow_dispatch', +); +check( + /^ permissions:\n contents: write\s*$/m.test(publisherWorkflow), + 'only the publisher receives contents: write', +); +check( + (releaseWorkflow.match(/^ contents: write\s*$/gm) ?? []).length === 1, + 'release workflow grants contents: write exactly once', +); +check( + !/\b(?:npm|pip|cargo|python3?)\b|actions\/checkout@|frontends\/desktop\/scripts\//.test(publisherWorkflow), + 'publisher does not check out source or run package/build tooling', +); +check( + (publisherWorkflow.match(/actions\/download-artifact@/g) ?? []).length === 3, + 'publisher downloads exactly three platform artifacts', +); +const expectedReleaseFiles = [ + 'GenericAgent-Desktop-Windows-Portable.zip', + 'SHA256SUMS-windows.txt', + 'GenericAgent-Desktop-Linux-Portable.tar.gz', + 'SHA256SUMS-linux.txt', + 'GenericAgent-Desktop-macOS-aarch64.dmg', + 'GenericAgent-Desktop-macOS-aarch64.dmg.sha256', +]; +for (const filename of expectedReleaseFiles) { + check(publisherWorkflow.includes(filename), `publisher expects release file: ${filename}`); +} +check( + publisherWorkflow.includes('Release staging must contain exactly the six expected files') + && (publisherWorkflow.match(/verify_checksum_manifest /g) ?? []).length === 3, + 'publisher strictly checks the six-file set and all three payload checksums', +); +check( + (publisherWorkflow.match(/\bgh release create\b/g) ?? []).length === 1 + && !publisherWorkflow.includes('gh release upload') + && publisherWorkflow.includes('--draft') + && publisherWorkflow.includes('gh release edit "$TAG_NAME" --draft=false --prerelease'), + 'publisher creates one draft with all assets, verifies it, then atomically exposes the prerelease', +); + +const macJobWorkflow = buildJobWorkflows['build-macos']; const postDmgScript = readText('scripts/post-dmg.sh'); const macInstallScript = readText('packaging/scripts/macos/install_macos.sh'); check( - macJobWorkflow.includes('python3 -m pip install --break-system-packages ds_store'), - 'macOS packaging installs the DMG layout dependency', + macJobWorkflow.includes('runs-on: macos-15') + && macJobWorkflow.includes('test "$(uname -m)" = arm64') + && macJobWorkflow.includes('aarch64-apple-darwin-install_only.tar.gz') + && !macJobWorkflow.includes('x86_64-apple-darwin'), + 'macOS packaging uses the explicit arm64 runner and runtime with an architecture assertion', +); +check( + /pip install --require-hashes --only-binary=:all:\s+--requirement frontends\/desktop\/packaging\/dmg-build-requirements\.txt/.test(macJobWorkflow), + 'macOS packaging hash-locks the DMG layout dependency', ); check( macJobWorkflow.includes('pip install --no-compile --no-index --find-links "$RUNTIME_SRC/wheels"') @@ -115,9 +303,9 @@ check( 'macOS packaging omits installed build tools and bytecode caches', ); check( - macJobWorkflow.includes('fastapi uvicorn websockets pydantic setuptools wheel') + !macJobWorkflow.includes('fastapi uvicorn websockets pydantic setuptools wheel') && macJobWorkflow.includes('(\"setuptools\", \"wheel\", \"pkg_resources\")'), - 'macOS packaging keeps recovery wheels but rejects build-tool runtime imports', + 'macOS packaging removes build tools from both the recovery wheelhouse and installed runtime', ); check( macJobWorkflow.includes('DMG_SITE_PACKAGES="$DMG_APP/Contents/Resources/runtime/python/lib/python3.12/site-packages"') @@ -129,13 +317,26 @@ check( check( macInstallScript.includes('export PYTHONDONTWRITEBYTECODE=1') && macInstallScript.includes('pip install --no-compile --no-index --find-links "$WHEEL_DIR"') + && macInstallScript.includes('--requirement "$locked_requirements"') && macInstallScript.includes('pip install --upgrade pip setuptools wheel'), - 'macOS offline repair is cache-free and keeps build-tool bootstrap online-only', + 'macOS offline repair consumes the exact lock while online source repair keeps build tools separate', ); check( - /bash frontends\/desktop\/scripts\/post-dmg\.sh "artifacts\/macos\/out\/GenericAgent-Desktop-macOS\.dmg"/.test(macJobWorkflow), + /bash frontends\/desktop\/scripts\/post-dmg\.sh "artifacts\/macos\/out\/GenericAgent-Desktop-macOS-aarch64\.dmg"/.test(macJobWorkflow), 'macOS packaging applies the curated Finder layout', ); +check( + !macJobWorkflow.includes('PORTABLE=') + && (macJobWorkflow.match(/codesign --force --deep --sign -/g) ?? []).length === 1 + && (macJobWorkflow.match(/codesign --verify --deep --strict/g) ?? []).length === 1 + && !/codesign --verify[^\n]*\|\| true/.test(macJobWorkflow) + && macJobWorkflow.includes('Ad-hoc signing only'), + 'macOS builds only the uploaded DMG and hard-fails ad-hoc signature verification', +); +check( + publisherWorkflow.includes('neither Developer ID signed nor notarized'), + 'release notes accurately disclose the macOS signing and notarization status', +); check( postDmgScript.includes('Only contains .app + Applications symlink + .DS_Store'), 'DMG post-processing keeps the curated two-item volume', From 39cc59f7876202d0bbe260d8c1bdb132ddf8e39e Mon Sep 17 00:00:00 2001 From: abraxas914 Date: Sat, 22 Aug 2026 23:25:25 +0800 Subject: [PATCH 5/5] docs(desktop): clarify React style ownership --- frontends/desktop/spec/FRONTEND_REFACTOR.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontends/desktop/spec/FRONTEND_REFACTOR.md b/frontends/desktop/spec/FRONTEND_REFACTOR.md index eaed7d1a0..3bce585bc 100644 --- a/frontends/desktop/spec/FRONTEND_REFACTOR.md +++ b/frontends/desktop/spec/FRONTEND_REFACTOR.md @@ -36,8 +36,11 @@ React stores/components ## 样式与组件 - Semi Design 提供主界面、loading、setup、异常页和确认框组件。 -- `public/styles.css` 是 React 主界面的独立生产样式,不链接 `static/styles.css`。 -- `public/fallback.html` 的自定义 class 统一使用 `.ga-` 前缀,保证独立资源损坏恢复能力。 +- React 主入口从 `src/global.css` 加载全局样式,各组件直接导入自己的 CSS;Vite 将它们编译为 + `dist/assets/**` 下的带哈希样式包,不加载已移除的 `public/styles.css`,也不链接 + `static/styles.css`。 +- `public/fallback.html` 自带内联样式和恢复脚本,不依赖 React、Semi、Vite chunk 或上述全局样式; + 其自定义 class 统一使用 `.ga-` 前缀,保证主资源损坏时仍可独立恢复。 - 首屏内联骨架只负责避免空白与闪烁,React 挂载后由正式组件接管。 ## 必要验证