diff --git a/spikes/dify-game-dev-node/README.md b/spikes/dify-game-dev-node/README.md new file mode 100644 index 00000000..1342c7f6 --- /dev/null +++ b/spikes/dify-game-dev-node/README.md @@ -0,0 +1,79 @@ +# dify 游戏开发节点(形态 A / a-min)—— 搭建配方与可复现留痕 + +本目录是内测 WU3「dify workflow 驱动 agentscope 生成」形态 A 的落地留痕。设计与边界见 [`docs/agent-specs/2026-07-07-内测-WU3-dify游戏开发节点-设计.md`](../../docs/agent-specs/2026-07-07-内测-WU3-dify游戏开发节点-设计.md);这里只放「照着做就能搭起来」的配方、一份可导入的 workflow 定义骨架,以及搭建时踩到的一个真配置阻塞。 + +## 一句话 + +在 mini-infra 那套已部署的 dify(`:18080`)里搭一条最小 workflow:开始节点收一句话创意 `brief` → 一个 HTTP Request「游戏开发节点」把它组成后端早已定义的 §6.1 job、POST 给便宜档 cheap-worker `:9501/generate` → 结束节点回显那次投递的响应。worker 入队即返 202,agentscope 在后台真跑一局,产物落在 worker 的 `game_dir`。后端一行不改。 + +## 现状:能力已证,但有一个必须先解的真阻塞 + +**能力这一层是通的。** 从 dify 宿主 mini-infra(`100.64.0.8`)直接 `curl --noproxy` 打一份最小 a-min job 到 mini-desktop(`100.64.0.7`)的 `:9501/generate`,worker 收下返 202、agentscope 真跑、worker 收口日志报 `status=succeeded`——这条端到端此前已实测走通。job 契约、生成核心、落盘全是现货,dify 只是又一个「组 job 的调用方」。 + +**但 dify 的 HTTP Request 节点不是直连,它经 dify 自带的 SSRF 代理出网,而那个代理默认把内网 worker 拦死。** dify 部署自带一个 squid 反 SSRF 代理(容器 `muse-dify-ssrf_proxy-1`,`ssrf_proxy:3128`),所有 HTTP 节点的出站请求都从它走。它的访问规则里有这么一条,且排在放行规则之前: + +``` +http_access deny to_private_networks # to_private_networks 含 100.64.0.0/10(Tailscale CGN 段) +http_access allow allowed_domains # 仅 .marketplace.dify.ai +http_access allow client_localnet +... +``` + +squid 首个匹配即生效,`100.64.0.7` 落在 `100.64.0.0/10` 里,先撞上 `deny to_private_networks` 就被拒,后面的放行规则根本到不了。实测坐实:从 dify-api 容器经 `ssrf_proxy:3128` 打 `100.64.0.7:9501` 返 **403**,而同样经它打允许域 `marketplace.dify.ai` 返 200。所以**直接在 dify UI 里配好 HTTP 节点点运行,会得到 403、连不上 worker**——这不是节点配错,是 dify 的 SSRF 防护按设计拦掉了所有私网目标(Tailscale 的 `100.64/10` 也算私网)。设计 §3.2/§3.5 预警过「HTTP 节点经代理出网需确认目标 IP 放行」,这里把它坐实了。 + +## 两个只有创始人能拍/能给的门 + +1. **放行 ssrf_proxy 到 worker 端点(共享基建 + 安全姿态变更,须创始人拍)。** 最小侵入的修法是在 squid 的 `deny to_private_networks` 之前插一条只放行这一个 host:port 的例外,而不是关掉整个 SSRF 防护: + + ``` + # 在 muse-dify-ssrf_proxy 的 squid.conf 里,deny to_private_networks 之前加: + acl gen_worker dst 100.64.0.7 + http_access allow gen_worker + ``` + + 然后 `docker exec muse-dify-ssrf_proxy-1 squid -k reconfigure`(或重启该容器)。这只对生成 worker 那一个内网地址开一道窄缝,其余私网仍全拦,回滚就是删掉这两行再 reconfigure。但它毕竟是在改一套别人今天刚建起来、且承担 SSRF 防护职责的共享 dify 实例,又是把一个零鉴权、会真花 new-api 钱的内网入口对 dify 放开(设计 §3.5 已指出 `:9501` 绑 `0.0.0.0`、无鉴权、成本型 DoS 面靠主机网络隔离兜底),所以属安全相关决定,交创始人拍,不擅动。 + +2. **dify console 登录(凭据档里没有,须创始人给)。** dify 已 setup 完成(owner 建于 2026-07-07),但 [`docs/内网凭据与端点.md`](../../docs/内网凭据与端点.md) 里没有 dify 的 console 账号密码。没有它无法登进 dify 控制台去搭/导入 workflow,也无法用 console API 导入下面这份 DSL。setup 已完成故不能再走 `/console/api/setup` 重建 owner。拿到登录后,导入或手搭都是几分钟的活。 + +## 搭建步骤(拿到上面两个门之后) + +**第一步 · worker 就位(已满足)。** dev 生成机 mini-desktop 上 cheap-worker(`:9501`)与 cheap Service(`:8300`)在跑,`:9501` 从 mini-infra TCP 可达、`GET /generate` 返 404(POST-only,证明在跑)。a-min 形态不带 `userToken`、不走回调,driver 用进程级全局 `NEWAPI_KEY`——dev 单一生成栈天然就是 dify 专用栈,与真实 create 路的 per-user 计费(WU2)在同栈上互斥,内测按「dev 单栈天然独立」成立(设计 §3.4)。 + +**第二步 · 放行 ssrf_proxy(门①)。** 见上。验证 = 从 dify-api 容器经 `ssrf_proxy:3128` 打 `:9501/generate` 不再 403。 + +**第三步 · dify 里建 workflow(门②)。** 登进 dify 控制台,导入 `workflow-游戏开发节点-formA.yml`(或按下节手搭三节点)。 + +**第四步 · 手动跑一次验收。** 在 dify 里给 `brief` 填一句创意点运行。成功判据 = HTTP 节点拿到 **202**(不是等生成完成——生成结果不从这个响应回来),随后到 mini-desktop 看 worker 收口日志出现 `status=succeeded`、`games/amgen-/` 落定 `src/` 产物。注意别把「`ls src/` 非空」当成功证据:scaffold 会在真生成前先克隆 `_template/src`(7 文件),一次 scaffold 成功但生成失败的 run 同样 src/ 非空。 + +## HTTP「游戏开发节点」配置(手搭时照填) + +- **方法 / URL**:`POST http://100.64.0.7:9501/generate`(dev worker 在 mini-desktop;若 worker 迁机改这个 IP)。 +- **超时**:握手级,约 **10 秒**。这是异步投递面,worker 入队立即返 202,真生成在后台跑几十秒到几分钟。**绝不能**把超时设成等生成完成的长值——结果经落盘/回调回流,不从这个 HTTP 响应回来。 +- **Body(application/json)**:一份 §6.1 job,a-min 形态省略 `callback`。便宜档生成核心真正只读 `brief` 与 `gameId`,其余字段给合理默认即可,缺了 worker 也不会失败: + + ```json + { + "job_id": "dify-{{#sys.workflow_run_id#}}", + "traceId": "dify-{{#sys.workflow_run_id#}}", + "tier": "cheap", + "kind": "generate", + "templateId": "generic", + "brief": "{{#start.brief#}}", + "model": "MiniMax-M2.7", + "gameId": "dify-{{#sys.workflow_run_id#}}", + "budget": { "maxYuan": 0.15, "maxLlmCalls": 8 }, + "idempotency_key":"dify-{{#sys.workflow_run_id#}}", + "deadline_ms": 600000 + } + ``` + + 几条写死的红线(读码坐实,别踩): + - **`traceId` 必带 `dify-` 前缀**做命名空间隔离,且令 `job_id`=`traceId`=`idempotency_key`=同一个 `dify-`。worker 按 `job_id`(缺则 `traceId`)在进程级 `_seen` 去重:同键重投直接返 202、不重入、不重复烧 token——所以 dify 侧对同一次运行配 HTTP 重试是**安全**的(重试复用同 `workflow_run_id`)。反过来,一次**真正的重新生成必须是一次新的 dify 运行**(新 `workflow_run_id` = 新 job_id);别在节点里每次重试新造 uuid(那才会重复生成),也别以为同 job_id 再 POST 能拿到新产物(会被静默去重照返 202)。`_seen` 是进程级、永不淘汰、worker 重启即清空。 + - **`budget.maxYuan` 是死字段,不承载封顶**,别据它以为「单局最多 ¥0.15」。单局真实成本上界由服务端 `cheap_budget` 固定的 ¥10 软停 / ¥15 硬地板决定(调用方 override 被直接 pop 掉)。dify 路真正的额度兜底只有一条:给这个 dify 专用 new-api token 在 new-api 侧配用量配额上限(按 ¥15/局估,配 ¥150 日额约 10 局/日触顶)。 + - **不要试图从 dify 节点透传 token 做 per-user 计费**——driver 的 key 只从进程级 env `NEWAPI_KEY` 取,job 里没有任何 token 位,透传做不到。隔离靠「这台 dev 栈就是 dify 专用栈」,不靠 job 自带凭据。 + +- **鉴权**:`/generate` 当前对入站不鉴权,信任边界靠内网不可外达。别为形态 A 单独给 worker 加鉴权(重复造设计已论证不该碰的服务间信任面);风险面(零鉴权 + 会花钱的内网入口)靠 `:9501` 所在主机 Tailscale-only/NAT/防火墙收口。 + +## 文件 + +- `workflow-游戏开发节点-formA.yml` —— dify 1.15.0 workflow 定义骨架(start → http-request → end)。**未经实测导入**(缺 console 登录无法验证),导入后须在 dify UI 内核对节点变量引用与超时,别当成拿来即跑。它是给「拿到 dify 登录后省手搭」的起点,手搭配方以本文上一节为准。 diff --git a/spikes/dify-game-dev-node/workflow-游戏开发节点-formA.yml b/spikes/dify-game-dev-node/workflow-游戏开发节点-formA.yml new file mode 100644 index 00000000..77decd5f --- /dev/null +++ b/spikes/dify-game-dev-node/workflow-游戏开发节点-formA.yml @@ -0,0 +1,93 @@ +# dify 1.15.0 workflow DSL 骨架 —— 内测「游戏开发节点」形态 A / a-min +# 用途:拿到 dify console 登录后,经「导入 DSL」省去手搭三节点的起点。 +# ⚠️ 未经实测导入(缺 console 登录无法验证)。导入后必须在 dify UI 内核对: +# ① HTTP 节点变量引用 {{#start.brief#}} / {{#sys.workflow_run_id#}} 是否解析; +# ② 超时设为握手级(约 10s,只等 202/503),绝不设成等生成完成的长超时; +# ③ 目标 IP 100.64.0.7 与 dev worker 实际所在机一致; +# ④ ssrf_proxy 已放行该内网端点(见 README「门①」),否则节点跑出来是 403。 +# 手搭配方以 README「HTTP 游戏开发节点配置」一节为准,本文件从属于它。 +app: + name: 游戏开发节点-形态A + description: 内测·把一句话创意组 §6.1 job 直连 cheap-worker :9501/generate,由 agentscope 生成小游戏(a-min:无 userToken、无回调,产物落 worker game_dir) + icon: "🎮" + icon_background: "#FFEAD5" + mode: workflow + use_icon_as_answer_icon: false +kind: app +version: 0.1.5 +workflow: + conversation_variables: [] + environment_variables: [] + features: {} + graph: + edges: + - id: start-to-http + source: startNode + target: gameDevNode + sourceHandle: source + targetHandle: target + type: custom + - id: http-to-end + source: gameDevNode + target: endNode + sourceHandle: source + targetHandle: target + type: custom + nodes: + - id: startNode + type: custom + position: { x: 80, y: 240 } + data: + type: start + title: 开始 + variables: + - variable: brief + label: 一句话创意 + type: paragraph + required: true + max_length: 1000 + - id: gameDevNode + type: custom + position: { x: 400, y: 240 } + data: + type: http-request + title: 游戏开发节点 + desc: 组 §6.1 job POST 给 cheap-worker :9501/generate(握手级超时,只等 202) + method: post + url: http://100.64.0.7:9501/generate + authorization: + type: no-auth + config: null + headers: "Content-Type:application/json" + params: "" + body: + type: json + data: | + { + "job_id": "dify-{{#sys.workflow_run_id#}}", + "traceId": "dify-{{#sys.workflow_run_id#}}", + "tier": "cheap", + "kind": "generate", + "templateId": "generic", + "brief": "{{#start.brief#}}", + "model": "MiniMax-M2.7", + "gameId": "dify-{{#sys.workflow_run_id#}}", + "budget": { "maxYuan": 0.15, "maxLlmCalls": 8 }, + "idempotency_key": "dify-{{#sys.workflow_run_id#}}", + "deadline_ms": 600000 + } + timeout: + max_connect_timeout: 5 + max_read_timeout: 10 + max_write_timeout: 10 + - id: endNode + type: custom + position: { x: 720, y: 240 } + data: + type: end + title: 结束 + outputs: + - variable: accept_status + value_selector: [gameDevNode, status_code] + - variable: accept_body + value_selector: [gameDevNode, body]