From 258501918f4bea607a8af2df8c56270f4a17ec84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E7=BE=BD=E7=A7=A6?= Date: Thu, 3 Sep 2026 10:35:23 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E9=A2=84=E8=A7=88=E5=B0=BA=E5=AF=B8=E6=8A=96=E5=8A=A8?= =?UTF-8?q?=20(#252)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 问题 AGC 项目运行页的本地预览会根据 iframe 上报内容尺寸调整 iframe viewport;部分使用 `100vh`、百分比或响应式布局的游戏会把宿主调整后的 viewport 再反映为新的内容尺寸,形成反馈环并在两个缩放档位之间持续抖动。 附件工程和录屏已确认可触发该问题,普通浏览器直接打开不受影响。 ## 落地方案 - 补充宿主 viewport 变更与真实内容变更的区分,忽略由宿主首次应用 fit 引起的回灌尺寸; - 保留同一稳定 viewport 下真实动态内容变化后的重新适配; - 增加反馈环收敛、动态内容变化和陈旧消息回归; - 同步 AGC 运行预览权威文档和项目记忆。 ## 验证计划 - 附件 `gameagent-2873e5ac` 在 AGC WebView 中稳定显示; - 相关 TypeScript 单测与类型检查; - preview Rust 定向测试; - `npm run check:encoding`、`git diff --check`。 Fixes #250 --------- Co-authored-by: 段舒康 Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/252 Reviewed-by: 段舒康 Co-authored-by: 董羽秦 Co-committed-by: 董羽秦 --- .../LocalGamePreviewFrame.tsx | 62 +++++-- .../tests/localGamePreviewFrame.test.ts | 168 +++++++++++++++++- .../shared-memory/decision-log.md | 4 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- 4 files changed, 220 insertions(+), 16 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx index 8b3671a2b..eae16c9c5 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx @@ -18,6 +18,11 @@ export type LocalGamePreviewFitLayout = { scale: number; }; +type LocalGamePreviewViewportSize = { + width: number; + height: number; +}; + export type LocalGamePreviewLike = { status?: string | null; url?: string | null; @@ -91,13 +96,30 @@ export function resolveLocalGamePreviewFitLayout( export function resolveLocalGamePreviewContentSizeUpdate( current: LocalGamePreviewContentSize | null, next: LocalGamePreviewContentSize, - nativeViewport: { width: number; height: number }, - appliedViewport: { width: number; height: number }, + nativeViewport: LocalGamePreviewViewportSize, + appliedViewport: LocalGamePreviewViewportSize, + previousReportedViewport: LocalGamePreviewViewportSize | null = null, ): LocalGamePreviewContentSize | null { - const reportsViewport = (viewport: { width: number; height: number }) => - Math.abs(next.viewportWidth - viewport.width) < 1 && - Math.abs(next.viewportHeight - viewport.height) < 1; - if (!reportsViewport(nativeViewport) && !reportsViewport(appliedViewport)) { + const reportsNativeViewport = previewReportMatchesViewport( + next, + nativeViewport, + ); + const reportsAppliedViewport = previewReportMatchesViewport( + next, + appliedViewport, + ); + if (!reportsNativeViewport && !reportsAppliedViewport) { + return current; + } + const followsViewportChange = + previousReportedViewport !== null && + !previewReportMatchesViewport(next, previousReportedViewport); + if ( + current && + reportsAppliedViewport && + !reportsNativeViewport && + followsViewportChange + ) { return current; } if ( @@ -114,6 +136,16 @@ export function resolveLocalGamePreviewContentSizeUpdate( }; } +function previewReportMatchesViewport( + report: LocalGamePreviewContentSize, + viewport: LocalGamePreviewViewportSize, +) { + return ( + Math.abs(report.viewportWidth - viewport.width) < 1 && + Math.abs(report.viewportHeight - viewport.height) < 1 + ); +} + export function LocalGamePreviewFrame({ preview, title, @@ -128,6 +160,9 @@ export function LocalGamePreviewFrame({ const iframeRef = useRef(null); const measuredContainerSizeRef = useRef({ width: 1, height: 1 }); const contentSizeRef = useRef(null); + const reportedViewportSizeRef = useRef( + null, + ); const [containerSize, setContainerSize] = useState({ width: 1, height: 1 }); const [contentSize, setContentSize] = useState(null); @@ -150,10 +185,6 @@ export function LocalGamePreviewFrame({ } measuredContainerSizeRef.current = next; setContainerSize(next); - if (contentSizeRef.current) { - contentSizeRef.current = null; - setContentSize(null); - } }; update(); if (typeof window.ResizeObserver === 'function') { @@ -166,6 +197,7 @@ export function LocalGamePreviewFrame({ }, [embeddedUrl]); useEffect(() => { + reportedViewportSizeRef.current = null; if (contentSizeRef.current) { contentSizeRef.current = null; setContentSize(null); @@ -190,12 +222,22 @@ export function LocalGamePreviewFrame({ nativeViewport, current, ); + const reportsCurrentViewport = + previewReportMatchesViewport(next, nativeViewport) || + previewReportMatchesViewport(next, appliedViewport); const resolved = resolveLocalGamePreviewContentSizeUpdate( current, next, nativeViewport, appliedViewport, + reportedViewportSizeRef.current, ); + if (reportsCurrentViewport) { + reportedViewportSizeRef.current = { + width: next.viewportWidth, + height: next.viewportHeight, + }; + } if (resolved === current) return; contentSizeRef.current = resolved; setContentSize(resolved); diff --git a/apps/ai-game-creator-shell/tests/localGamePreviewFrame.test.ts b/apps/ai-game-creator-shell/tests/localGamePreviewFrame.test.ts index a5c93c5c1..c26d643f5 100644 --- a/apps/ai-game-creator-shell/tests/localGamePreviewFrame.test.ts +++ b/apps/ai-game-creator-shell/tests/localGamePreviewFrame.test.ts @@ -1,13 +1,173 @@ -import { describe, expect, it } from 'vitest'; +// @vitest-environment jsdom + +import { act, render } from '@testing-library/react'; +import { createElement } from 'react'; +import { describe, expect, it, vi } from 'vitest'; import { LOCAL_GAME_PREVIEW_SIZE_MESSAGE, + LocalGamePreviewFrame, parseLocalGamePreviewContentSize, resolveLocalGamePreviewContentSizeUpdate, resolveLocalGamePreviewFitLayout, } from '../src/features/project-workspace/LocalGamePreviewFrame'; describe('local game preview viewport fitting', () => { + it('does not reset the fitted iframe to native size while its container resizes', () => { + let containerRect = { width: 800, height: 500 }; + let resizeCallback: ResizeObserverCallback | null = null; + const rectSpy = vi + .spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockImplementation( + () => + ({ + ...containerRect, + x: 0, + y: 0, + top: 0, + right: containerRect.width, + bottom: containerRect.height, + left: 0, + toJSON: () => ({}), + }) as DOMRect, + ); + const previousResizeObserver = window.ResizeObserver; + window.ResizeObserver = class { + constructor(callback: ResizeObserverCallback) { + resizeCallback = callback; + } + + observe() {} + unobserve() {} + disconnect() {} + }; + + const view = render( + createElement(LocalGamePreviewFrame, { + preview: { status: 'running', url: 'http://127.0.0.1:1234/' }, + title: 'preview', + }), + ); + const iframe = view.getByTitle('preview') as HTMLIFrameElement; + act(() => { + window.dispatchEvent( + new MessageEvent('message', { + origin: 'http://127.0.0.1:1234', + source: iframe.contentWindow, + data: { + type: LOCAL_GAME_PREVIEW_SIZE_MESSAGE, + contentWidth: 800, + contentHeight: 835, + viewportWidth: 800, + viewportHeight: 500, + }, + }), + ); + }); + expect(iframe.style.height).toBe('835px'); + + containerRect = { width: 1000, height: 600 }; + act(() => { + if (!resizeCallback) throw new Error('ResizeObserver was not registered'); + resizeCallback([], {} as ResizeObserver); + }); + expect(iframe.style.width).toBe('1000px'); + expect(iframe.style.height).toBe('835px'); + + view.unmount(); + window.ResizeObserver = previousResizeObserver; + rectSpy.mockRestore(); + }); + + it('keeps the current fit while the iframe reports its first host-applied viewport measurement', () => { + const nativeViewport = { width: 1200, height: 700 }; + const appliedViewport = { width: 1200, height: 1000 }; + const current = { + contentWidth: 1200, + contentHeight: 1000, + viewportWidth: 1200, + viewportHeight: 700, + }; + const firstAppliedViewportReport = { + contentWidth: 1200, + contentHeight: 700, + viewportWidth: 1200, + viewportHeight: 1000, + }; + + expect( + resolveLocalGamePreviewContentSizeUpdate( + current, + firstAppliedViewportReport, + nativeViewport, + appliedViewport, + nativeViewport, + ), + ).toBe(current); + }); + + it('does not let a delayed native report restart the fitted viewport loop', () => { + const nativeViewport = { width: 1200, height: 700 }; + const appliedViewport = { width: 1200, height: 1000 }; + const current = { + contentWidth: 1200, + contentHeight: 1000, + viewportWidth: 1200, + viewportHeight: 700, + }; + const delayedNativeReport = { + contentWidth: 1200, + contentHeight: 1000, + viewportWidth: 1200, + viewportHeight: 700, + }; + + expect( + resolveLocalGamePreviewContentSizeUpdate( + current, + delayedNativeReport, + nativeViewport, + appliedViewport, + appliedViewport, + ), + ).toBe(current); + }); + + it('keeps the current fit while a resized container applies its next viewport', () => { + const resizedContainer = { width: 1000, height: 600 }; + const current = { + contentWidth: 800, + contentHeight: 835, + viewportWidth: 800, + viewportHeight: 500, + }; + const appliedViewport = resolveLocalGamePreviewFitLayout( + resizedContainer, + current, + ); + const resizedViewportReport = { + contentWidth: 1000, + contentHeight: 818, + viewportWidth: appliedViewport.width, + viewportHeight: appliedViewport.height, + }; + + expect(appliedViewport).toEqual({ + width: 1000, + height: 835, + scale: 600 / 835, + }); + expect( + resolveLocalGamePreviewContentSizeUpdate( + current, + resizedViewportReport, + resizedContainer, + appliedViewport, + { width: 800, height: 835 }, + ), + ).toBe(current); + }); + it('keeps a game at native size when its content fits', () => { expect( resolveLocalGamePreviewFitLayout( @@ -55,7 +215,7 @@ describe('local game preview viewport fitting', () => { ).toEqual(report); }); - it('accepts changed content after the iframe has adopted the first fit viewport', () => { + it('accepts changed content after the fitted iframe viewport has stabilized', () => { const nativeViewport = { width: 1200, height: 700 }; const current = { contentWidth: 1200, @@ -79,6 +239,7 @@ describe('local game preview viewport fitting', () => { width: 1200, height: 1000, }, + { width: 1200, height: 1000 }, ), ).toEqual({ contentWidth: 1200, @@ -88,7 +249,7 @@ describe('local game preview viewport fitting', () => { }); }); - it('shrinks the fitted frame when content becomes shorter inside the applied viewport', () => { + it('shrinks the fitted frame when content becomes shorter inside a stable applied viewport', () => { const nativeViewport = { width: 1200, height: 700 }; const current = { contentWidth: 1200, @@ -108,6 +269,7 @@ describe('local game preview viewport fitting', () => { changed, nativeViewport, { width: 1200, height: 1000 }, + { width: 1200, height: 1000 }, ); expect(updated).toEqual({ contentWidth: 1200, diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 2a7d67394..ff1b03c2f 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7794,8 +7794,8 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 2026-08-23 AI 游戏运行视窗按预览文档实际尺寸自适应 - 背景:项目开发工作台的中央运行视窗尺寸小于部分生成游戏的页面布局高度时,滚动条来自 loopback iframe 内部;宿主只隐藏 overflow 会直接裁掉标题、Canvas 或控制区,不能满足完整试玩。 -- 决策:客户端本地 preview server 为 UTF-8 HTML 注入固定同源尺寸桥;注入器按真实 HTML tokenizer 边界保守处理注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text、template、plaintext、foreign content 与重复 `src`,省略结束标签时只在已证明安全的文档位置注入。桥通过根节点 `ResizeObserver`、页面 load、窗口 resize 与字体就绪重新测量;页面可见时以 `500ms` 低频兜底探测至多 `512` 个元素边界,探测截断时不采用可能低估的部分样本,并排除随 viewport 同步变化的布局自反馈。它不订阅整页 DOM 突变,并只在尺寸元组真实变化时上报文档与浏览上下文宽高。宿主只接受当前 iframe source 与当前授权 loopback origin 的固定版本消息,按实际内容和可用容器计算最大为 `1` 的等比缩放并居中显示;首次缩放后仍接受内容宽高真实变化,重复内容尺寸或仅 viewport 回灌不更新状态,容器 resize 后重新以原生视口测量。运行视窗不再提供 iframe 横纵滚动条,内容适配不改游戏文件、manifest、PreviewRegistry 或运行业务状态,非 UTF-8 HTML 保持原样。 -- 验证:前端纯函数覆盖无需缩放、纵向超高缩放、首次缩放后的增高 / 缩短、重复内容尺寸去重、过期 viewport 与非法消息;Rust preview server 测试锁定尺寸去重、无全页 MutationObserver、低频有界探测、截断保护、固定 body 与 viewport 耦合布局不振荡、真实 HTML 上下文注入、注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text / template / plaintext / foreign content、省略结束标签、大小写结束标签、重复 `src` 和幂等注入;再以桌面最小窗口和更高窗口人工确认完整画面、动态内容变化后仍适配、无纵向滚动条且指针 / 键盘交互仍可用。 +- 决策:客户端本地 preview server 为 UTF-8 HTML 注入固定同源尺寸桥;注入器按真实 HTML tokenizer 边界保守处理注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text、template、plaintext、foreign content 与重复 `src`,省略结束标签时只在已证明安全的文档位置注入。桥通过根节点 `ResizeObserver`、页面 load、窗口 resize 与字体就绪重新测量;页面可见时以 `500ms` 低频兜底探测至多 `512` 个元素边界,探测截断时不采用可能低估的部分样本,并排除随 viewport 同步变化的布局自反馈。它不订阅整页 DOM 突变,并只在尺寸元组真实变化时上报文档与浏览上下文宽高。宿主只接受当前 iframe source 与当前授权 loopback origin 的固定版本消息,按实际内容和可用容器计算最大为 `1` 的等比缩放并居中显示;宿主把最近一次合法上报的 viewport 与正式内容尺寸分开保存,首次收到自身 fit 切换产生的新 viewport 测量时只推进观察值、不反向改写 fit,viewport 稳定后的真实内容增减仍可重新适配。容器 resize 期间保留当前内容尺寸和已观察 viewport,只按新的可用空间连续重算缩放,避免拖动窗口时在原生尺寸与 fit 之间闪烁;preview URL 变化时才清空两者并重新测量。陈旧 viewport、重复内容尺寸和首次宿主回灌均不更新状态。运行视窗不再提供 iframe 横纵滚动条,内容适配不改游戏文件、manifest、PreviewRegistry 或运行业务状态,非 UTF-8 HTML 保持原样。 +- 验证:前端组件测试锁定容器 resize 时 iframe 不恢复原生尺寸;纯函数覆盖无需缩放、纵向超高缩放、宿主首次应用 viewport 时保持当前 fit、容器 resize 后保持当前 fit、稳定 viewport 下内容增高 / 缩短、重复内容尺寸去重、过期 viewport 与非法消息;Rust preview server 测试锁定尺寸去重、无全页 MutationObserver、低频有界探测、截断保护、固定 body 与 viewport 耦合布局不振荡、真实 HTML 上下文注入、注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text / template / plaintext / foreign content、省略结束标签、大小写结束标签、重复 `src` 和幂等注入;再以 Issue #250 附件的 `min-height: 100vh` 页面在桌面最小窗口和更高窗口人工确认完整画面、无循环缩放、拖动窗口时无原生尺寸闪切、动态内容变化后仍适配、无纵向滚动条且指针 / 键盘交互仍可用。 ## 2026-08-23 Direct Codex 显式重生成与切片一等资源 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 5b59e156c..e4a1db383 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -561,7 +561,7 @@ game-project/ - 中间主视窗提供 `resource-overview / asset-canvas / resource-editor / run` 四种状态。2026-08-10 起普通用户“新增资源”显示为禁用态且处理函数拒绝 create;所有现役资源从聚焦态“编辑资源”进入非破坏性派生。静态图片继续进入 refine 素材创作无限画布,SVG、视频、音频、文档/代码、Agent 回执和项目版本进入统一资源编辑壳并按能力分流;底层 create 合同仅保留兼容。编辑面板只替换中央区域,不覆盖右侧 Supervisor 或底部 Agent。`code-prototype` 任务完成前运行入口保持视觉不可用,但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled` 或 `aria-disabled`;完成后才允许进入运行表现层。切回资源总览只修改前端展示态,不伪造后端预览暂停结果。 - 资源管理从当前 `GameCreationAppManifest`(包含可选 `versions`)、合法 Agent 文本回执、已导入附件和已完成任务明确登记的产物派生资源,固定按文档、项目版本、美术资源、音乐音效资源分区;未知任务产物不再兜底为版本,未完成任务或未在 `artifacts` 中登记的任意本地音频也不冒充正式资源。`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。 - 资源卡支持点击聚焦、搜索和类型筛选。2026-07-28 起完成两套二维坐标与本地 CAS sidecar;2026-07-31 起 dependency 模式增加不持久化的原生 SVG 关系图层。2026-08-03 mentor 决定暂缓资源总览卡片拖动,当前卡片不挂载 Pointer Down / Move / Up / Cancel 拖动入口,只允许自动布局和点击聚焦。聚焦态替换中央主视窗内容,保留左侧导航、右侧对话和底部 Agent 状态栏,退出后恢复搜索、布局模式、滚动位置与选中资源;不提供通用工具栏、工具侧边栏或可拖动标题栏。阶段四已补齐安全本地文档、扩展美术媒体与音频聚焦,正文独立滚动,视频 / 音频使用内置媒体控件,失败显示空态。2026-08-30 视觉验收修正:资源总览所有栏目初次适配与复位最多以 `1.5` 倍缩放卡片,避免单个低尺寸卡片被插值放大成糊图;用户主动缩放仍沿用通用画布倍率,并按“排序模式 + 栏目”保留当前会话内的平移和缩放。美术资源聚焦态改为视口级大预览,保留原始资源读取与元数据,不生成第二份缩略图,图片 / 视频预览按弹窗可用高度展示并允许正文滚动。该资源总览边界不限制后续素材创作无限画布内的图片图层移动/缩放、生成和正式回写。 -- 运行表现层首版直接嵌入当前项目的 loopback 游戏画面,并保留素材信息和数值微调面板;两个面板保持原有 `156px` 最小高度,没有真实数据时只让正文为空,不渲染预设字段、默认数值、未载入控件或自然语言功能占位,也不随空内容收缩。`preview.start` 启动本地 server 后把真实 URL 回写工作台,`preview.open` 只激活客户端内运行视图,不再调用系统浏览器;参数调整首版仍只保留本地 UI 草稿,不修改代码或 manifest。preview server 对 UTF-8 HTML 响应注入固定同源尺寸桥脚本;注入点通过真实 HTML tokenizer 边界定位,保守处理注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text、template、plaintext、foreign content 与重复 `src`,并支持省略 `` / ``。桥以 `ResizeObserver` 观察 `documentElement / body` 根布局,结合页面 load、窗口 resize 与字体就绪重新测量;页面可见时另以 `500ms` 低频兜底探测至多 `512` 个元素的实际边界,探测截断时保留 body / scroll 上界,并按连续测量排除随 viewport 同步变化的 `100vh / 100% / bottom / right` 自反馈。相同尺寸元组去重后才以固定版本 `postMessage` 上报,不订阅整页 `MutationObserver`。宿主同时校验消息 origin 和 `event.source`,以实际内容宽高与当前容器宽高计算不超过 `1` 的等比缩放;首次适配后仍接受内容宽高的真实变化,但仅 viewport 回灌或重复内容尺寸不更新 React 状态。容器 resize 后回到原生视口重新测量;放得下时保持 `1:1`,超出时完整缩小并居中,iframe 禁止横纵滚动条,不能以 `overflow: hidden` 直接裁掉超出内容。非 UTF-8 HTML 原样返回,不因适配桥破坏已有预览。 +- 运行表现层首版直接嵌入当前项目的 loopback 游戏画面,并保留素材信息和数值微调面板;两个面板保持原有 `156px` 最小高度,没有真实数据时只让正文为空,不渲染预设字段、默认数值、未载入控件或自然语言功能占位,也不随空内容收缩。`preview.start` 启动本地 server 后把真实 URL 回写工作台,`preview.open` 只激活客户端内运行视图,不再调用系统浏览器;参数调整首版仍只保留本地 UI 草稿,不修改代码或 manifest。preview server 对 UTF-8 HTML 响应注入固定同源尺寸桥脚本;注入点通过真实 HTML tokenizer 边界定位,保守处理注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text、template、plaintext、foreign content 与重复 `src`,并支持省略 `` / ``。桥以 `ResizeObserver` 观察 `documentElement / body` 根布局,结合页面 load、窗口 resize 与字体就绪重新测量;页面可见时另以 `500ms` 低频兜底探测至多 `512` 个元素的实际边界,探测截断时保留 body / scroll 上界,并按连续测量排除随 viewport 同步变化的 `100vh / 100% / bottom / right` 自反馈。相同尺寸元组去重后才以固定版本 `postMessage` 上报,不订阅整页 `MutationObserver`。宿主同时校验消息 origin 和 `event.source`,以实际内容宽高与当前容器宽高计算不超过 `1` 的等比缩放;宿主单独记录最近一次合法上报的 iframe viewport,首次收到由自身 fit 切换产生的新 viewport 测量时只确认该 viewport、不反向改写内容尺寸,待 viewport 稳定后仍接受真实内容宽高变化,从而阻断 `100vh` / 百分比布局在两个适配尺寸之间回灌振荡。重复内容尺寸不更新 React 状态,陈旧 viewport 消息继续忽略。容器 resize 期间保留内容尺寸与已观察 viewport,只按新容器尺寸连续重算缩放,避免拖动窗口时在原生尺寸和 fit 之间闪烁;preview URL 变化时才清空状态并重新测量。放得下时保持 `1:1`,超出时完整缩小并居中,iframe 禁止横纵滚动条,不能以 `overflow: hidden` 直接裁掉超出内容。非 UTF-8 HTML 原样返回,不因适配桥破坏已有预览。 - 右侧继续复用现有 Project Supervisor 会话、Runtime 澄清和确认链路;输入区展示 `严格审批 / 风险审批 / 无需审批` 独立面板。P0 只有严格审批可选;风险审批和无需审批保持视觉不可用但允许点击查看原因,不替代 Runtime 的逐动作权限、确认、sandbox 或 reconciliation 门禁。风险 Rank 算法记录在 `docs/project-memory/todos/【待解决】AI游戏创作高风险审批Rank-2026-07-20.md`,前端不得自行计算。 - 底部状态栏默认展示策划、美术、程序 3 组,并允许在同一栏展开数值、音频、发布组;状态来自 manifest 与当前 Supervisor run 的 Runtime,悬停显示当前任务与进度。累计泥点必须等待后端计费归因投影;Agent.md 编辑和自定义 Skill 在来源审核、版本、权限、sandbox 与回滚合同完备前不向普通用户开放。 - 当前 run 专业状态与项目历史成果分离:状态继续严格匹配当前 `parentRunId`;已有文本成果从专业 Agent 持久对话中合法的 `agent-finalization-<32 lower hex>` assistant 恢复,并以“历史成果”来源投影到资源管理文档区。新 run 失败、待确认、候选为空或持久对话瞬时读取失败不得清除已恢复的旧成功回执,普通失败 assistant 也不得被当作成果。 From 53070e0390216d2319058a13805995c609a3c4ae Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 3 Sep 2026 10:36:43 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E6=94=AF=E6=8C=81=20AGC=20=E5=A4=9A?= =?UTF-8?q?=E8=A1=8C=E5=8F=91=E5=B8=83=E8=AF=B4=E6=98=8E=20Jenkins=20?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E6=94=B9=E4=B8=BA=E5=A4=9A=E8=A1=8C=E6=96=87?= =?UTF-8?q?=E6=9C=AC=E8=BE=93=E5=85=A5=20=E5=AE=A2=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=8F=90=E7=A4=BA=E4=BF=9D=E7=95=99=E6=8D=A2?= =?UTF-8?q?=E8=A1=8C=E5=B9=B6=E6=94=AF=E6=8C=81=E6=BB=9A=E5=8A=A8=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=B8=85=E5=8D=95=E4=B8=8E=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E5=A4=9A=E8=A1=8C=E8=AF=B4=E6=98=8E=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=20=E5=90=8C=E6=AD=A5=20AGC=20=E6=9B=B4=E6=96=B0=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/build-release.test.mjs | 14 ++++++++++++++ apps/ai-game-creator-shell/src/styles.css | 7 ++++--- apps/ai-game-creator-shell/tests/appUpdate.test.ts | 12 ++++++++++++ ...技术方案】AGC客户端更新检查与下载-2026-08-31.md | 4 ++-- jenkins/Jenkinsfile.ai-game-creator-shell-build | 2 +- 5 files changed, 33 insertions(+), 6 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index 798bd206f..d2853082f 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -41,6 +41,20 @@ test('manifest contains version, download URL and integrity fields', () => { assert.equal(typeof manifest.size, 'number'); }); +test('manifest preserves multiline release notes', () => { + const previous = process.env.AGC_UPDATE_RELEASE_NOTES; + process.env.AGC_UPDATE_RELEASE_NOTES = '第一行\n第二行\r\n第三行'; + try { + const manifest = createUpdateManifest( + new URL('../package.json', import.meta.url).pathname, + ); + assert.equal(manifest.releaseNotes, '第一行\n第二行\r\n第三行'); + } finally { + if (previous === undefined) delete process.env.AGC_UPDATE_RELEASE_NOTES; + else process.env.AGC_UPDATE_RELEASE_NOTES = previous; + } +}); + test('next release version follows the higher local or OSS version', () => { assert.equal(compareVersions('0.1.15', '0.1.12'), 1); assert.equal(nextPatchVersion('0.1.12', '0.1.15'), '0.1.16'); diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 38f586369..ee8e4e96d 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -55,9 +55,10 @@ body { } .app-update-notice p { max-width: 320px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + max-height: 120px; + overflow-y: auto; + overflow-wrap: anywhere; + white-space: pre-wrap; } .app-update-notice button { flex: 0 0 auto; diff --git a/apps/ai-game-creator-shell/tests/appUpdate.test.ts b/apps/ai-game-creator-shell/tests/appUpdate.test.ts index 1348ce9c2..8bbe1e596 100644 --- a/apps/ai-game-creator-shell/tests/appUpdate.test.ts +++ b/apps/ai-game-creator-shell/tests/appUpdate.test.ts @@ -32,4 +32,16 @@ describe('AGC update manifest', () => { }), ).toBeNull(); }); + + it('preserves multiline release notes', () => { + expect( + parseAppUpdateManifest({ + version: '0.1.13', + downloadUrl: 'https://oss.example/agc.exe', + releaseNotes: '第一行\n第二行\r\n第三行', + }), + ).toMatchObject({ + releaseNotes: '第一行\n第二行\r\n第三行', + }); + }); }); diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 455c37670..29eae7a7f 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -38,7 +38,7 @@ OSS 请求失败、清单格式错误或版本无效会终止发布,避免覆 生成包含版本、下载地址、大小和 SHA-256 的清单。可通过 `AGC_BUILD_TARGET` 显式覆盖目标(发布仍应使用 Windows x64),通过 `AGC_UPDATE_ARTIFACT` 指定要发布的安装包,通过 `AGC_UPDATE_OSS_BASE_URL` 指定 OSS 前缀,通过 `AGC_RELEASE_VERSION` 指定三段版本号(仅在明确需要复现指定版本时使用),通过 -`AGC_UPDATE_RELEASE_NOTES` 写入发布说明;`--no-bundle` smoke 构建不会读取 OSS、修改版本或生成清单。 +`AGC_UPDATE_RELEASE_NOTES` 写入发布说明,支持多行文本且保留内部换行;`--no-bundle` smoke 构建不会读取 OSS、修改版本或生成清单。 每次发布安装包上传完成后,再使用 ossutil 的 `--force` 覆盖上传同一目录生成的 `latest.json`,确保固定的 latest 指针和 `downloadUrl` 指向已存在的 OSS 对象;未显式强制覆盖时,ossutil 在目标已存在时会交互询问并按默认值跳过,不能作为 Jenkins 非交互发布方式。清单和安装包均使用公开可读对象,不在清单中保存凭据、签名或本地路径。构建脚本本身不负责上传 OSS,发布流水线通过 `release:upload` 完成上传。 @@ -56,7 +56,7 @@ Jenkins Agent 服务必须能在同一用户环境中找到这些命令。Tauri `tauri.windows.conf.json` 中的 `bundle.useLocalToolsDir: true`,把固定版本的 NSIS 工具缓存到 `src-tauri/target/.tauri/NSIS`,不依赖 Jenkins 服务账户的 `%LOCALAPPDATA%\tauri` 或 PATH 中的系统 NSIS。 Jenkins Checkout 的 `git clean -fdx` 会清理该构建目录,因此每次全新工作区可能重新下载 NSIS;这只影响构建耗时,不改变工具来源或执行权限要求。 -流水线执行根 workspace 的 `npm ci`,然后调用 +流水线参数 `AGC_UPDATE_RELEASE_NOTES` 使用 Jenkins `text` 类型,可直接输入多行发布说明;执行根 workspace 的 `npm ci`,然后调用 `npm run ai-game-creator-shell:release:upload`,并归档 Windows 安装包、`latest.json` 与源码 commit。 流水线会将未导出的空参数按空字符串处理:`COMMIT_HASH` 留空时沿用 Jenkins SCM 当前提交,`OSSUTIL_BIN` 留空时使用节点 PATH 中的 `ossutil`,不会因 PowerShell 对空环境变量调用 `.Trim()` 而提前失败。 diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-build b/jenkins/Jenkinsfile.ai-game-creator-shell-build index 8803fa375..3e304cf63 100644 --- a/jenkins/Jenkinsfile.ai-game-creator-shell-build +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-build @@ -22,7 +22,7 @@ pipeline { string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '源码分支') string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,指定属于 SOURCE_BRANCH 的 Git commit') string(name: 'AGC_RELEASE_VERSION', defaultValue: '', description: '可选,指定三段版本号;留空则按 OSS 与本地版本自动递增 patch') - string(name: 'AGC_UPDATE_RELEASE_NOTES', defaultValue: '', description: '可选,写入 latest.json 的发布说明') + text(name: 'AGC_UPDATE_RELEASE_NOTES', defaultValue: '', description: '可选,支持多行文本,写入 latest.json 的发布说明') string(name: 'OSSUTIL_BIN', defaultValue: 'ossutil', description: 'ossutil 或 ossutil.exe 的绝对路径/命令名') } From 8ed0626627de363221933462d74fa66efdb68ea7 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 3 Sep 2026 11:38:40 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E6=81=A2=E5=A4=8D=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E7=8E=AF=E5=A2=83=E6=9C=AC=E5=9C=B0=E9=85=8D=E7=BD=AE=E6=B3=A8?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 恢复 API 与 worker 的 .env.local BuildKit secret 挂载 补齐 Jenkins 固定文件校验与预览文档 增加双配置文件构建门禁 --- deploy/container/README.md | 6 +- deploy/container/api-server.Dockerfile | 8 +++ docs/project-memory/shared-memory/pitfalls.md | 4 +- ...Jenkins容器预览部署控制面技术方案-2026-08-15.md | 6 +- jenkins/Jenkinsfile.preview-deployer | 1 + scripts/check-preview-deployer.mjs | 51 +++++++++++++++-- scripts/jenkins-preview-deployer.sh | 56 ++++++++++++++----- 7 files changed, 104 insertions(+), 28 deletions(-) diff --git a/deploy/container/README.md b/deploy/container/README.md index 6631905a1..ed97a2924 100644 --- a/deploy/container/README.md +++ b/deploy/container/README.md @@ -60,11 +60,11 @@ Linux Docker Engine 若要从宿主机 CLI 连到容器内服务,直接用 `ht ### Jenkins 预览 secrets 镜像边界 -Jenkins 分支预览构建固定从宿主 `/data/jenkins/preview-secrets/.env.secrets.local` 读取 secrets。目录由 Jenkins 运行账号所有且权限为 `0700`,文件由同一账号所有且权限为 `0600`;构建入口对缺失、链接、非普通文件、owner 不匹配和过宽权限均失败关闭。不要把真实值写入本 README、仓库示例或 Jenkins 参数。 +Jenkins 分支预览构建固定从宿主 `/data/jenkins/preview-secrets/.env.local` 与 `/data/jenkins/preview-secrets/.env.secrets.local` 读取运行时配置。目录由 Jenkins 运行账号所有且权限为 `0700`,两个文件由同一账号所有且权限为 `0600`;构建入口对缺失、链接、非普通文件、owner 不匹配和过宽权限均失败关闭。两个文件都包含敏感配置,不要把真实值写入本 README、仓库示例或 Jenkins 参数。 -该文件不复制到源码 checkout 和 Docker build context,而是以 BuildKit `secret` mount 只提供给 `api-runtime` stage。构建会把它安装到 API 运行镜像的 `/srv/genarrative/.env.secrets.local`,owner 为 `genarrative`、权限为 `0400`。Web builder、`nginx-runtime`、SpacetimeDB 和其它运行镜像不得获得该 mount 或目标文件;构建日志和 artifact 也不得回显或保存文件内容。容器的显式运行环境变量优先于该内置文件,可按预览实例覆盖其中的值。 +两个文件都不复制到源码 checkout 和 Docker build context,而是分别以 BuildKit `secret` mount 只提供给 `api-runtime` stage。构建会把它们安装到 API 运行镜像的 `/srv/genarrative/.env.local` 与 `/srv/genarrative/.env.secrets.local`,owner 为 `genarrative`、权限为 `0400`。Web builder、`nginx-runtime`、SpacetimeDB 和其它运行镜像不得获得这些 mount 或目标文件;构建日志和 artifact 也不得回显或保存文件内容。容器的显式运行环境变量优先于这两个内置文件,可按预览实例覆盖其中的值。 -修改宿主固定文件后必须重新构建并替换 API 镜像;重启旧容器不会读取宿主新内容。这个镜像不是可公开分发的无密钥产物:镜像持有者可以提取 `/srv/genarrative/.env.secrets.local`。只允许在当前受信任内网 Docker 主机使用,禁止 push 或 `docker save`、artifact 导出到跨信任边界的 registry、主机或存储。 +修改任一宿主固定文件后必须重新构建并替换 API 与 worker 镜像;重启旧容器不会读取宿主新内容。这个镜像不是可公开分发的无密钥产物:镜像持有者可以提取 `/srv/genarrative/.env.local` 与 `/srv/genarrative/.env.secrets.local`。只允许在当前受信任内网 Docker 主机使用,禁止 push 或 `docker save`、artifact 导出到跨信任边界的 registry、主机或存储。 ### Gitea CI 预构建 Job 镜像 diff --git a/deploy/container/api-server.Dockerfile b/deploy/container/api-server.Dockerfile index d97732bce..e61d73eae 100644 --- a/deploy/container/api-server.Dockerfile +++ b/deploy/container/api-server.Dockerfile @@ -24,12 +24,20 @@ RUN mkdir -p /var/lib/genarrative/auth /var/lib/genarrative/tracking-outbox /var chown -R genarrative:genarrative /srv/genarrative /var/lib/genarrative ARG GENARRATIVE_PREVIEW_SECRETS_SHA256= +ARG GENARRATIVE_PREVIEW_ENV_LOCAL_SHA256= RUN --mount=type=secret,id=genarrative_preview_secrets,required=false \ + --mount=type=secret,id=genarrative_preview_env_local,required=false \ if [ -n "${GENARRATIVE_PREVIEW_SECRETS_SHA256}" ]; then \ test -f /run/secrets/genarrative_preview_secrets; \ test "$(sha256sum /run/secrets/genarrative_preview_secrets | cut -d ' ' -f 1)" = "${GENARRATIVE_PREVIEW_SECRETS_SHA256}"; \ install -o genarrative -g genarrative -m 0400 \ /run/secrets/genarrative_preview_secrets /srv/genarrative/.env.secrets.local; \ + fi; \ + if [ -n "${GENARRATIVE_PREVIEW_ENV_LOCAL_SHA256}" ]; then \ + test -f /run/secrets/genarrative_preview_env_local; \ + test "$(sha256sum /run/secrets/genarrative_preview_env_local | cut -d ' ' -f 1)" = "${GENARRATIVE_PREVIEW_ENV_LOCAL_SHA256}"; \ + install -o genarrative -g genarrative -m 0400 \ + /run/secrets/genarrative_preview_env_local /srv/genarrative/.env.local; \ fi USER genarrative diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 5860fb644..722ae47c9 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4926,8 +4926,8 @@ - 现象:构建时使用 BuildKit secret mount,日志和普通 build context 都没有出现明文,于是误以为最终镜像也能不可提取地保存 secrets,随后将镜像 push 或导出给不同信任域。 - 原因:BuildKit secret mount 只避免秘密作为 `ARG` / `COPY` 进入构建上下文和中间指令;一旦 Dockerfile 把 mount 的内容安装到最终 rootfs,任何能读取、保存或运行该镜像的主体都可以提取它。 -- 处理:预览固定 secrets 只从 Jenkins 宿主受控路径读取,严格校验目录 `0700`、文件 `0600`、owner、普通文件与非链接边界;只将其安装到 `api-runtime:/srv/genarrative/.env.secrets.local` 并设为 `0400`,明确排除 Nginx、Web、artifact 和其它镜像。镜像禁止推送或导出到跨信任边界。 -- 更新与验证:源文件变更不会改动已存镜像,必须重建并替换容器;不能用重启代替。验收同时扫描 transcript/context/artifact 零泄漏,检查只有 API 最终 rootfs 存在目标文件,并验证容器显式运行 env 优先覆盖内置值。 +- 处理:预览固定 `.env.local` 与 secrets 只从 Jenkins 宿主受控路径读取,严格校验目录 `0700`、文件 `0600`、owner、普通文件与非链接边界;只将它们安装到 `api-runtime:/srv/genarrative/.env.local` 与 `/srv/genarrative/.env.secrets.local` 并设为 `0400`,明确排除 Nginx、Web、artifact 和其它镜像。镜像禁止推送或导出到跨信任边界。 +- 更新与验证:任一源文件变更不会改动已存镜像,必须重建并替换 API 与 worker 容器;不能用重启代替。验收同时扫描 transcript/context/artifact 零泄漏,检查只有 API 与 worker 最终 rootfs 存在目标文件,并验证容器显式运行 env 优先覆盖内置值。 ## SpacetimeDB ping 健康不代表完整模块能在内存上限内实例化(2026-08-22) diff --git a/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md b/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md index 782224108..6fcf77ff7 100644 --- a/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md +++ b/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md @@ -51,9 +51,9 @@ SpacetimeDB 2.7 CLI 发布到受控 Compose 网络地址时固定使用 `--yes=r ## 预览 secrets 内置 -Jenkins 节点上的预览 secrets 只允许来自受控的 Jenkins 凭据目录(目录与文件权限、owner、普通文件和非链接约束由流水线检查)。该文件不进 Git、Docker build context、构建日志或 artifact;构建时只通过 BuildKit `secret` mount 临时提供给 `api-runtime` stage,运行镜像权限固定为 `0400`。`nginx-runtime`、Web 静态产物、SpacetimeDB 镜像及其它镜像不得包含该文件。 +Jenkins 节点上的预览 `.env.local` 与 secrets 只允许来自受控的 Jenkins 凭据目录(目录与文件权限、owner、普通文件和非链接约束由流水线检查)。固定宿主副本不进 Git、Docker build context、构建日志或 artifact;构建时分别通过两个 BuildKit `secret` mount 临时提供给 `api-runtime` stage,并安装为 `/srv/genarrative/.env.local` 与 `/srv/genarrative/.env.secrets.local`,运行镜像权限固定为 `0400`。`nginx-runtime`、Web 静态产物、SpacetimeDB 镜像及其它镜像不得包含这些文件;仓库工作区 `.env.local` 不得替代固定宿主副本。 -宿主固定目录应由 Jenkins 运行账号所有且权限为 `0700`,源文件权限为 `0600`;缺失、不是普通文件、owner 不匹配或权限过宽时,预览构建必须失败关闭。源文件变更后必须重新构建并替换预览镜像,只重启容器不会刷新已内置的内容。容器启动时显式注入的运行环境变量优先级高于镜像内的 `.env.secrets.local`,用于按实例覆盖非通用值。 +宿主固定目录应由 Jenkins 运行账号所有且权限为 `0700`,两个源文件权限均为 `0600`;缺失、不是普通文件、owner 不匹配或权限过宽时,预览构建必须失败关闭。任一源文件变更后必须重新构建并替换 API 与 worker 预览镜像,只重启容器不会刷新已内置的内容。容器启动时显式注入的运行环境变量优先级高于镜像内的 `.env.local` 与 `.env.secrets.local`,用于按实例覆盖非通用值。 这种方案只隐藏构建传输过程,不能让内置后的 secrets 对镜像持有者保密:能读取、保存或运行 `api-runtime` 镜像的人可以提取该文件。因此该镜像只能留在当前受信任内网 Docker 主机,禁止 push 到公共或跨信任边界的 registry,也禁止通过 `docker save`/构建 artifact 导出传播。需要跨边界分发时必须改用不含 secrets 的镜像与运行时密钥注入。 @@ -118,7 +118,7 @@ Jenkins 在构建完成、归档 artifact 和更新 REST 状态之间可能短 - Jenkins service account 只授予 `shared/Genarrative-Preview-Deployer` 的 `Job/Read`、`Job/Build` 和读取构建产物所需权限,不授 `Overall/Administer`、`Job/Configure` 或 `Job/Delete`。 - 后端固定 Jenkins origin、Job 路径和参数白名单;客户端不能传 URL、Job 名、Compose project、容器名、宿主端口或 Jenkins 凭据。 - Git 查询固定使用本机 Gitea SSH 地址和服务端只读凭据;客户端不能传 remote、SSH 参数或凭据。Git 缓存只写入预览控制服务的受控状态目录,搜索接口需要控制台会话且结果有数量上限。 -- 预览 secrets 只从固定宿主路径读取,构建前校验 owner、类型和权限;不允许分支、Jenkins 参数或控制面请求改写 secrets 路径、BuildKit secret ID 或镜像内目标路径。 +- 预览 `.env.local` 与 secrets 只从固定宿主路径读取,构建前校验目录和文件的 owner、类型和权限;不允许分支、Jenkins 参数或控制面请求改写这些路径、BuildKit secret ID 或镜像内目标路径。 - Jenkins POST 支持动态 Crumb;API Token 即使免 Crumb,也不能把 Token 放进 URL 或日志。 - API 默认只接受同源请求,写请求校验 Origin;内网本身不作为认证。 - 同一 deployment 的发布和卸载串行执行;重复请求必须幂等或明确返回冲突。 diff --git a/jenkins/Jenkinsfile.preview-deployer b/jenkins/Jenkinsfile.preview-deployer index d63b06067..46daea974 100644 --- a/jenkins/Jenkinsfile.preview-deployer +++ b/jenkins/Jenkinsfile.preview-deployer @@ -15,6 +15,7 @@ pipeline { GIT_REMOTE_CREDENTIAL_ID = 'genarrative-local-gitea-ssh' GENARRATIVE_PREVIEW_STATE_ROOT = '/data/jenkins/preview-deployments' GENARRATIVE_PREVIEW_SECRETS_FILE = '/data/jenkins/preview-secrets/.env.secrets.local' + GENARRATIVE_PREVIEW_ENV_LOCAL_FILE = '/data/jenkins/preview-secrets/.env.local' GENARRATIVE_PREVIEW_WEB_HOST = '192.168.35.82' } diff --git a/scripts/check-preview-deployer.mjs b/scripts/check-preview-deployer.mjs index a5f3268ba..c79fa3946 100644 --- a/scripts/check-preview-deployer.mjs +++ b/scripts/check-preview-deployer.mjs @@ -139,20 +139,40 @@ assertIncludes( 'GENARRATIVE_PREVIEW_SECRETS_SHA256', '预览构建必须把固定 secrets 文件摘要作为镜像缓存与完整性校验参数。', ); +assertIncludes( + deployer, + 'GENARRATIVE_PREVIEW_ENV_LOCAL_SHA256', + '预览构建必须把固定 .env.local 文件摘要作为镜像缓存与完整性校验参数。', +); assertIncludes( jenkinsfile, "GENARRATIVE_PREVIEW_SECRETS_FILE = '/data/jenkins/preview-secrets/.env.secrets.local'", 'Jenkins 必须从受保护的固定宿主路径读取预览 secrets。', ); assertIncludes( - deployer, - '[[ "${secrets_mode}" == "600" ]]', - '预览构建必须拒绝权限过宽的 secrets 文件。', + jenkinsfile, + "GENARRATIVE_PREVIEW_ENV_LOCAL_FILE = '/data/jenkins/preview-secrets/.env.local'", + 'Jenkins 必须从受保护的固定宿主路径读取预览 .env.local。', ); assertIncludes( deployer, - '[[ "${secrets_owner}" == "${EUID}" ]]', - '预览构建必须校验 secrets 文件归 Jenkins 执行用户所有。', + '[[ "${file_mode}" == "600" ]]', + '预览固定输入文件必须拒绝权限过宽。', +); +assertIncludes( + deployer, + '[[ "${dir_mode}" == "700" ]]', + '预览固定输入文件所在目录必须拒绝权限过宽。', +); +assertIncludes( + deployer, + '[[ "${file_owner}" == "${EUID}" ]]', + '预览固定输入文件必须校验 owner 归 Jenkins 执行用户所有。', +); +assertIncludes( + deployer, + '[[ "${dir_owner}" == "${EUID}" ]]', + '预览固定输入文件所在目录必须校验 owner 归 Jenkins 执行用户所有。', ); assertIncludes( deployer, @@ -165,11 +185,22 @@ assertCount( 2, '预览 secrets 必须且只能提供给 API 和外部生成 worker 两个构建。', ); +assertCount( + deployer, + 'target: genarrative_preview_env_local', + 2, + '预览 .env.local 必须且只能提供给 API 和外部生成 worker 两个构建。', +); assertIncludes( apiServerDockerfile, 'ARG GENARRATIVE_PREVIEW_SECRETS_SHA256=', 'API 镜像必须允许普通构建不提供预览 secrets 摘要。', ); +assertIncludes( + apiServerDockerfile, + 'ARG GENARRATIVE_PREVIEW_ENV_LOCAL_SHA256=', + 'API 镜像必须允许普通构建不提供预览 .env.local 摘要。', +); assertIncludes( apiServerDockerfile, 'RUN --mount=type=secret,id=genarrative_preview_secrets,required=false', @@ -195,6 +226,16 @@ assertIncludes( '/run/secrets/genarrative_preview_secrets /srv/genarrative/.env.secrets.local;', '预览 secrets 文件必须安装到 API 启动时读取的固定路径。', ); +assertIncludes( + apiServerDockerfile, + '--mount=type=secret,id=genarrative_preview_env_local,required=false', + 'API 镜像必须通过可选 BuildKit secret 接收预览 .env.local 文件。', +); +assertIncludes( + apiServerDockerfile, + '/run/secrets/genarrative_preview_env_local /srv/genarrative/.env.local;', + '预览 .env.local 文件必须安装到 API 启动时读取的固定路径。', +); assertIncludes( deployer, 'GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true', diff --git a/scripts/jenkins-preview-deployer.sh b/scripts/jenkins-preview-deployer.sh index ec1e7dcb2..b4354fcd1 100644 --- a/scripts/jenkins-preview-deployer.sh +++ b/scripts/jenkins-preview-deployer.sh @@ -11,6 +11,7 @@ RESULT_FILE="${RESULT_FILE:-${WORKSPACE:-$(pwd)}/preview-result.json}" DESCRIPTION_FILE="${DESCRIPTION_FILE:-${WORKSPACE:-$(pwd)}/.jenkins-preview-description}" STATE_ROOT="${GENARRATIVE_PREVIEW_STATE_ROOT:-/data/jenkins/preview-deployments}" PREVIEW_SECRETS_FILE="${GENARRATIVE_PREVIEW_SECRETS_FILE:-/data/jenkins/preview-secrets/.env.secrets.local}" +PREVIEW_ENV_LOCAL_FILE="${GENARRATIVE_PREVIEW_ENV_LOCAL_FILE:-/data/jenkins/preview-secrets/.env.local}" WEB_HOST="${GENARRATIVE_PREVIEW_WEB_HOST:-}" LOCK_FILE="${GENARRATIVE_PREVIEW_LOCK_FILE:-${STATE_ROOT}/.lock}" @@ -20,6 +21,7 @@ PROJECT_NAME="" SCRIPT_ROOT="" SCRIPT_FAILED=1 PREVIEW_SECRETS_SHA256="" +PREVIEW_ENV_LOCAL_SHA256="" fail() { echo "[preview-deployer] $*" >&2 @@ -246,25 +248,39 @@ allocate_port() { fail "端口范围 ${start}-${end} 已无可用端口。" } -validate_preview_secrets_file() { - local secrets_dir secrets_mode secrets_owner canonical_secrets canonical_source - [[ "${PREVIEW_SECRETS_FILE}" == /* ]] || fail "预览 secrets 文件必须使用绝对路径。" - [[ -f "${PREVIEW_SECRETS_FILE}" && ! -L "${PREVIEW_SECRETS_FILE}" && -r "${PREVIEW_SECRETS_FILE}" ]] || \ - fail "预览 secrets 文件必须是 Jenkins 可读的非符号链接普通文件: ${PREVIEW_SECRETS_FILE}" - secrets_dir="$(dirname "${PREVIEW_SECRETS_FILE}")" - [[ -d "${secrets_dir}" && ! -L "${secrets_dir}" ]] || \ - fail "预览 secrets 目录必须是非符号链接目录: ${secrets_dir}" - secrets_mode="$(stat -c '%a' "${PREVIEW_SECRETS_FILE}")" - [[ "${secrets_mode}" == "600" ]] || fail "预览 secrets 文件权限必须是 0600: ${PREVIEW_SECRETS_FILE}" - secrets_owner="$(stat -c '%u' "${PREVIEW_SECRETS_FILE}")" - [[ "${secrets_owner}" == "${EUID}" ]] || fail "预览 secrets 文件必须归当前 Jenkins 执行用户所有。" - canonical_secrets="$(realpath -e "${PREVIEW_SECRETS_FILE}")" +validate_preview_input_file() { + local file="$1" + local label="$2" + local file_dir file_mode file_owner dir_mode dir_owner canonical_file canonical_source + [[ "${file}" == /* ]] || fail "${label}必须使用绝对路径。" + [[ -f "${file}" && ! -L "${file}" && -r "${file}" ]] || \ + fail "${label}必须是 Jenkins 可读的非符号链接普通文件: ${file}" + file_dir="$(dirname "${file}")" + [[ -d "${file_dir}" && ! -L "${file_dir}" ]] || \ + fail "${label}所在目录必须是非符号链接目录: ${file_dir}" + dir_mode="$(stat -c '%a' "${file_dir}")" + [[ "${dir_mode}" == "700" ]] || fail "${label}所在目录权限必须是 0700: ${file_dir}" + dir_owner="$(stat -c '%u' "${file_dir}")" + [[ "${dir_owner}" == "${EUID}" ]] || fail "${label}所在目录必须归当前 Jenkins 执行用户所有。" + file_mode="$(stat -c '%a' "${file}")" + [[ "${file_mode}" == "600" ]] || fail "${label}权限必须是 0600: ${file}" + file_owner="$(stat -c '%u' "${file}")" + [[ "${file_owner}" == "${EUID}" ]] || fail "${label}必须归当前 Jenkins 执行用户所有。" + canonical_file="$(realpath -e "${file}")" canonical_source="$(realpath -e "${SOURCE_DIR}")" - [[ "${canonical_secrets}" != "${canonical_source}"/* ]] || \ - fail "预览 secrets 文件不能位于目标分支源码上下文内。" + [[ "${canonical_file}" != "${canonical_source}"/* ]] || \ + fail "${label}不能位于目标分支源码上下文内。" +} + +validate_preview_secrets_file() { + validate_preview_input_file "${PREVIEW_SECRETS_FILE}" '预览 secrets 文件' + validate_preview_input_file "${PREVIEW_ENV_LOCAL_FILE}" '预览 .env.local 文件' PREVIEW_SECRETS_SHA256="$(sha256sum "${PREVIEW_SECRETS_FILE}")" PREVIEW_SECRETS_SHA256="${PREVIEW_SECRETS_SHA256%% *}" [[ "${PREVIEW_SECRETS_SHA256}" =~ ^[0-9a-f]{64}$ ]] || fail "无法计算预览 secrets 文件摘要。" + PREVIEW_ENV_LOCAL_SHA256="$(sha256sum "${PREVIEW_ENV_LOCAL_FILE}")" + PREVIEW_ENV_LOCAL_SHA256="${PREVIEW_ENV_LOCAL_SHA256%% *}" + [[ "${PREVIEW_ENV_LOCAL_SHA256}" =~ ^[0-9a-f]{64}$ ]] || fail "无法计算预览 .env.local 文件摘要。" } remove_project_resources() { @@ -298,6 +314,8 @@ compose() { GENARRATIVE_PREVIEW_CONTROLLER_ROOT="${SCRIPT_ROOT}/.." \ GENARRATIVE_PREVIEW_SECRETS_FILE="${PREVIEW_SECRETS_FILE}" \ GENARRATIVE_PREVIEW_SECRETS_SHA256="${PREVIEW_SECRETS_SHA256}" \ + GENARRATIVE_PREVIEW_ENV_LOCAL_FILE="${PREVIEW_ENV_LOCAL_FILE}" \ + GENARRATIVE_PREVIEW_ENV_LOCAL_SHA256="${PREVIEW_ENV_LOCAL_SHA256}" \ GENARRATIVE_CONTAINER_API_ENV_FILE="${STATE_DIR}/api-server.env" \ GENARRATIVE_CONTAINER_HTTP_PORT="${WEB_PORT}" \ GENARRATIVE_CONTAINER_SPACETIME_PORT="${SPACETIME_PORT}" \ @@ -319,18 +337,24 @@ services: dockerfile: ${GENARRATIVE_PREVIEW_CONTROLLER_ROOT}/deploy/container/api-server.Dockerfile args: GENARRATIVE_PREVIEW_SECRETS_SHA256: ${GENARRATIVE_PREVIEW_SECRETS_SHA256} + GENARRATIVE_PREVIEW_ENV_LOCAL_SHA256: ${GENARRATIVE_PREVIEW_ENV_LOCAL_SHA256} secrets: - source: preview_runtime_env target: genarrative_preview_secrets + - source: preview_runtime_env_local + target: genarrative_preview_env_local external-generation-worker: build: context: ${GENARRATIVE_PREVIEW_SOURCE_DIR} dockerfile: ${GENARRATIVE_PREVIEW_CONTROLLER_ROOT}/deploy/container/api-server.Dockerfile args: GENARRATIVE_PREVIEW_SECRETS_SHA256: ${GENARRATIVE_PREVIEW_SECRETS_SHA256} + GENARRATIVE_PREVIEW_ENV_LOCAL_SHA256: ${GENARRATIVE_PREVIEW_ENV_LOCAL_SHA256} secrets: - source: preview_runtime_env target: genarrative_preview_secrets + - source: preview_runtime_env_local + target: genarrative_preview_env_local restart: on-failure nginx: build: @@ -344,6 +368,8 @@ services: secrets: preview_runtime_env: file: ${GENARRATIVE_PREVIEW_SECRETS_FILE} + preview_runtime_env_local: + file: ${GENARRATIVE_PREVIEW_ENV_LOCAL_FILE} YAML chmod 0600 "${override_file}" }