Skip to content

Commit 2a3ed86

Browse files
committed
feat: add workflow file context
1 parent df6dc47 commit 2a3ed86

34 files changed

Lines changed: 1482 additions & 584 deletions

File tree

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
# Workflow 输入文件上下文统一设计
2+
3+
## 1. 背景
4+
5+
Workflow 输入文件目前以 URL 字符串贯穿 AI Chat、ToolCall、AgentV2、ReadFiles 和
6+
Sandbox。URL 同时承担模型访问地址、服务端下载地址、文件类型推断和去重标识,导致同源
7+
绝对 URL、相对下载短链和外部 URL 容易被错误转换。
8+
9+
尤其需要避免以下行为:
10+
11+
- 根据 `requestOrigin``FE_DOMAIN` 或 URL 前缀推断文件权限;
12+
- 将用户输入的相对 URL 交给内部 Axios;
13+
- 让模型或节点通过新 URL 扩大本轮 Workflow 可读取的文件集合;
14+
- 使用模型 URL 作为私有对象的服务端读取来源。
15+
16+
本期引入请求级、只读的 `WorkflowFileContext`。它在 `dispatchWorkFlow` 入口统一处理当前
17+
query 和 histories 已携带的文件,下游消费者只能解析入口已登记的文件。
18+
19+
## 2. 本期范围
20+
21+
### 2.1 包含
22+
23+
- 当前 query 中的文件;
24+
- histories 中 Human 消息携带的文件;
25+
- AI Chat、ToolCall、AgentV2、ReadFiles 和 Sandbox 对上述文件的消费;
26+
- 私有 chat object key 的归属校验;
27+
- 外部绝对 HTTP(S) URL 的 SSRF 防护读取;
28+
- 请求级文件数量和单文件大小限制。
29+
30+
### 2.2 不包含
31+
32+
- Workflow 运行期间由普通节点、Plugin、Interactive 或 Dataset Search 新产生的文件;
33+
- Workflow 文件变量的存储结构迁移;
34+
- 跨请求持久化 `WorkflowFileRef`
35+
- 修改聊天数据库中的现有文件格式;
36+
- 通过 signed alias 反推业务归属;
37+
- Context clone、分支注册和合并。
38+
39+
## 3. 核心原则
40+
41+
### 3.1 key 是私有文件的可信来源
42+
43+
私有文件必须携带 `key`。Workflow 入口使用 `parseChatFileS3Key`
44+
`isAuthorizedChatFileS3Key` 校验 key 是否属于根 Workflow:
45+
46+
```text
47+
key
48+
-> sourceType
49+
-> sourceId
50+
-> uid
51+
-> chatId
52+
-> 与根 Workflow 鉴权上下文逐项匹配
53+
```
54+
55+
匹配使用根 `dispatchWorkFlow``runningAppInfo``uid``chatId`。Child Workflow 不得
56+
使用 child app 的身份重新解释父 Workflow 输入文件。
57+
58+
归属匹配失败时直接拒绝。不能把失败的私有 key 降级成外部 URL。
59+
60+
### 3.2 signed URL 只用于模型访问
61+
62+
signed URL 不参与权限判断。私有 key 校验通过后,每个 key 在一次 Workflow 中最多签发
63+
一次两小时有效的绝对 HTTP(S) URL,并在请求内复用。
64+
65+
如果文件域名配置无法生成绝对 HTTP(S) URL,入口应报配置错误,不能生成相对 URL。
66+
67+
### 3.3 服务端读取与模型 URL 分离
68+
69+
- 私有 chat object:FastGPT 服务端通过 S3 client 直接读取;
70+
- 外部 HTTP(S) URL:FastGPT 服务端通过带 SSRF 防护的 Axios 读取;
71+
- 模型只接收 `modelUrl`
72+
- 相对 URL、protocol-relative URL 和非 HTTP(S) 协议一律不能进入读取链路。
73+
74+
### 3.4 Context 只读
75+
76+
只有 Workflow 入口可以登记文件。节点、模型工具参数和下游消费者只能查询已登记 Ref,
77+
不能在查询失败时自动注册新外链。
78+
79+
## 4. 数据模型
80+
81+
```ts
82+
export type WorkflowFileSource =
83+
| {
84+
type: 'chatObject';
85+
objectKey: string;
86+
}
87+
| {
88+
type: 'externalHttp';
89+
url: string;
90+
};
91+
92+
export type WorkflowFileRef = {
93+
id: string;
94+
name: string;
95+
type: ChatFileTypeEnum;
96+
modelUrl: string;
97+
source: WorkflowFileSource;
98+
};
99+
100+
export type WorkflowFileLimits = {
101+
maxFiles: number;
102+
maxBytesPerFile: number;
103+
};
104+
105+
export type WorkflowFileContext = {
106+
resolve: (urlOrId: string) => WorkflowFileRef | undefined;
107+
resolveChatFile: (url: string) => UserChatItemFileItemType | undefined;
108+
getIdentity: (urlOrId: string) => string | undefined;
109+
read: (target: string | WorkflowFileRef) => Promise<WorkflowFileReadResult>;
110+
limits: WorkflowFileLimits;
111+
};
112+
```
113+
114+
Context 不暴露 `register``clone` 或文件枚举接口。内部至少维护:
115+
116+
- `byId`:服务端生成 file id 到 Ref;
117+
- `byRuntimeUrl`:本轮 `modelUrl` 到 Ref;
118+
- `byIdentity`:私有 key 或完整外部 URL 到 Ref,用于去重。
119+
120+
## 5. Workflow 入口
121+
122+
入口处理顺序:
123+
124+
```text
125+
完成 Workflow/Chat 鉴权
126+
-> 计算根 Workflow 文件 scope
127+
-> 计算 maxFiles/maxBytesPerFile
128+
-> 扫描 query + histories 文件
129+
-> 校验 chat object key 归属
130+
-> 校验外链必须为绝对 HTTP(S)
131+
-> 对私有 key 按请求缓存签发两小时 modelUrl
132+
-> 按 key/完整外链 URL 去重并建立只读 Context
133+
-> 将 FileContext 挂载到 WorkflowContext
134+
-> 开始节点调度
135+
```
136+
137+
### 5.1 Query
138+
139+
- 带 key:完整校验 `sourceType/sourceId/uid/chatId`,失败则拒绝本轮 Workflow;
140+
- 无 key:只允许绝对 HTTP(S) URL,并继续应用 `fileUrlWhitelist`
141+
- 其他 URL:拒绝本轮 Workflow。
142+
143+
### 5.2 Histories
144+
145+
- 带 key:使用根 Workflow scope 校验并签发 URL;
146+
- 无 key的绝对 HTTP(S) URL:登记为外链;
147+
- 无 key的相对或非法 URL:不登记,使用该文件时返回不可用,不能交给内部 Axios;
148+
- 无关历史文件不可用时,不能阻塞整轮 Workflow。
149+
150+
入口只登记来源和签发 URL,不下载所有历史文件正文。正文继续按消费者需求懒加载。
151+
152+
## 6. 读取规则
153+
154+
### 6.1 私有对象
155+
156+
`WorkflowFileContext.read` 根据 Ref 中已校验的 bucket/objectKey 使用 S3 client:
157+
158+
1. 先读取 metadata;
159+
2. `contentLength` 超过 `maxBytesPerFile` 时拒绝;
160+
3. 下载流再次按累计字节数限制;
161+
4. 返回 Buffer、filename 和 contentType。
162+
163+
### 6.2 外部 URL
164+
165+
外部 URL 始终使用带 SSRF 防护的 Axios:
166+
167+
- 只允许 `http:``https:`
168+
- 保留 `fileUrlWhitelist`
169+
- SSRF Axios 负责 DNS、metadata 地址和逐跳重定向检查;
170+
- 先检查 `Content-Length`,流式下载时再次限制累计字节数;
171+
- 禁止使用 `pickOutboundAxios` 或任何内部相对路径 Axios。
172+
173+
### 6.3 大小限制
174+
175+
`dispatchWorkFlow` 接受可选的 `maxFileSize`(字节)。调用入口可以根据团队套餐和系统限制
176+
计算后传入;本期未传入时固定使用 2GB。
177+
178+
该限制约束 FastGPT 服务端读取。模型提供商直接拉取外部 `modelUrl` 时,不经过 FastGPT
179+
下载链路,因此不计入服务端读取限制。
180+
181+
## 7. 消费者
182+
183+
### 7.1 AI Chat
184+
185+
- 普通文档 URL 必须先命中 Context,再由统一读取器获取正文;
186+
- 图片、音频、视频默认使用 `modelUrl`
187+
- LLM 层不感知 Workflow Context;`shouldLoadMediaAsBase64` 为真时统一使用 AI 层已有的
188+
下载与 Base64 转换能力,否则把绝对 `modelUrl` 直接交给模型。
189+
190+
### 7.2 ToolCall 和 AgentV2 read_file
191+
192+
- 模型只提交 file id;
193+
- file id 必须命中节点已知文件映射,并最终命中 WorkflowFileContext;
194+
- 未知 id/URL 返回文件不可用,不能注册新外链;
195+
- 文档解析继续复用 `core/chat/fileContext.ts`
196+
197+
### 7.3 ReadFiles
198+
199+
ReadFiles 的字符串 URL 输入保持兼容,但只有命中 WorkflowFileContext 的 URL 才能读取。
200+
201+
### 7.4 Sandbox
202+
203+
Workflow 显式向 Sandbox 文件注入步骤传入读取函数。该函数通过 WorkflowFileContext 读取
204+
Buffer 后写入 Sandbox;Sandbox 通用模块不引用 Workflow,也不使用 `requestOrigin`、相对 URL
205+
或内部 Axios。没有注入读取函数的普通外链仍走带 SSRF 防护的 Axios。
206+
207+
### 7.5 Child、Loop 和 Parallel
208+
209+
本期 Context 创建后保持只读,因此根 Workflow、Child、Loop 和 Parallel 共享同一请求级
210+
Context,不需要 clone 或 merge。下游只拿实际传入的 URL/file id 查询,Context 不提供枚举。
211+
212+
## 8. 兼容策略
213+
214+
- 文件类型、名称和 key 等运行态元数据统一由 WorkflowFileContext 提供,不再维护额外的
215+
query URL Map;
216+
- WorkflowFileContext 复用 Workflow 自己的 AsyncLocalStorage,并由 Workflow 调用方显式传给
217+
Chat/Sandbox;`core/ai``core/chat` 不得反向引用 `core/workflow`
218+
- 字符串 URL 运行值继续保留;
219+
- keyless 历史绝对 URL 按外链处理;
220+
- keyless 历史相对 URL 不再通过内部 Axios 读取;
221+
- AgentV2 的 `normalizeAgentServerFileUrl``internalUrl` 过渡实现应删除;
222+
- Workflow 以外的普通聊天文件链路暂不纳入本期 Context,但共享读取函数不能再接受相对 URL。
223+
224+
## 9. 测试要求
225+
226+
### 9.1 Context
227+
228+
- query/history 私有 key 归属校验成功;
229+
- sourceType/sourceId/uid/chatId 任一不匹配时拒绝;
230+
- 同一个 key 在 query/history 中只签发一次并只创建一个 Ref;
231+
- 外部绝对 HTTP(S) URL 可登记;
232+
- 相对 URL、protocol-relative、`file:``data:``ws:` 被拒绝或跳过;
233+
- `fileUrlWhitelist` 继续生效;
234+
- `resolve` 不会自动登记未知绝对 URL。
235+
236+
### 9.2 读取
237+
238+
- 私有对象通过 S3 client 读取;
239+
- 外链通过 SSRF Axios 读取;
240+
- 私有对象和外链均执行 header/metadata 与流式双重大小限制;
241+
- 私有对象读取不调用 HTTP;
242+
- 相对 URL 不进入任何 Axios。
243+
244+
### 9.3 消费者
245+
246+
- AI Chat、ToolCall、AgentV2、ReadFiles 使用同一 Context;
247+
- Agent history 和当前输入都保留绝对 modelUrl;
248+
- read_file 未知 id 无法读取;
249+
- Sandbox 注入通过 Context Buffer;
250+
- 不可用的无关 history 文件不阻塞本轮请求。
251+
252+
## 10. TODO
253+
254+
- [x] 新增只读 `WorkflowFileContext` 和入口准备函数;
255+
- [x]`dispatchWorkFlow` 增加 `maxFileSize`,创建并挂载 Context;
256+
- [x] `parseUrlToFileType` 优先读取 Context 文件元数据;
257+
- [x] 统一 `core/chat/fileContext.ts` 的 Workflow 文件读取;
258+
- [x] 保持 AI Chat 多模态 Base64 与 Workflow Context 解耦;
259+
- [x] 迁移 ToolCall、AgentV2 和 ReadFiles;
260+
- [x] 迁移 Sandbox 文件注入;
261+
- [x] 删除 AgentV2 `requestOrigin/internalUrl` 过渡逻辑;
262+
- [x] 补齐 Context、读取器和消费者测试;
263+
- [x] 运行全量测试;Admin 既有批处理性能用例在全量并发下超时,隔离复跑 31/31 通过。
264+
265+
## 11. 相关代码
266+
267+
- `packages/service/core/workflow/utils/context.ts`
268+
- `packages/service/core/workflow/utils/fileContext.ts`
269+
- `packages/service/core/workflow/dispatch/index.ts`
270+
- `packages/service/core/chat/fileContext.ts`
271+
- `packages/service/core/workflow/dispatch/ai/chat/chatMessages.ts`
272+
- `packages/service/core/workflow/dispatch/ai/toolcall/tools/file.ts`
273+
- `packages/service/core/workflow/dispatch/ai/agent/adapter/userContext.ts`
274+
- `packages/service/core/workflow/dispatch/ai/agent/sub/file/index.ts`
275+
- `packages/service/core/ai/sandbox/application/file.ts`
276+
- `packages/service/core/workflow/dispatch/tools/readFiles.ts`
277+
- `packages/service/common/api/axios.ts`
278+
- `packages/service/common/s3/sources/chat/key.ts`

document/content/self-host/upgrading/4-15/4154.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ description: 'FastGPT V4.15.4 更新说明'
77

88
## ⚙️ 优化
99

10+
1. 工作流文件上下文管理,减少重复签发以及避免潜在安全问题。
11+
1012
## 🐛 修复
1113

1214
1. chatbox 流输出时候,不应该展示系统工具的错误。

document/data/doc-last-modified.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,8 @@
324324
"content/self-host/upgrading/4-15/4152.mdx": "2026-07-18T12:51:59+08:00",
325325
"content/self-host/upgrading/4-15/4153.en.mdx": "2026-07-18T12:51:59+08:00",
326326
"content/self-host/upgrading/4-15/4153.mdx": "2026-07-18T12:51:59+08:00",
327+
"content/self-host/upgrading/4-15/4154.en.mdx": "2026-07-21T13:14:50+08:00",
328+
"content/self-host/upgrading/4-15/4154.mdx": "2026-07-21T13:14:50+08:00",
327329
"content/self-host/upgrading/outdated/40.en.mdx": "2026-04-26T21:08:47+08:00",
328330
"content/self-host/upgrading/outdated/40.mdx": "2026-04-26T21:08:47+08:00",
329331
"content/self-host/upgrading/outdated/41.en.mdx": "2026-04-26T21:08:47+08:00",
@@ -464,6 +466,6 @@
464466
"content/self-host/upgrading/outdated/499.mdx": "2026-05-07T15:06:40+08:00",
465467
"content/self-host/upgrading/upgrade-intruction.en.mdx": "2026-04-26T21:08:47+08:00",
466468
"content/self-host/upgrading/upgrade-intruction.mdx": "2026-04-26T21:08:47+08:00",
467-
"content/toc.en.mdx": "2026-07-18T12:51:59+08:00",
468-
"content/toc.mdx": "2026-07-18T12:51:59+08:00"
469+
"content/toc.en.mdx": "2026-07-21T13:14:50+08:00",
470+
"content/toc.mdx": "2026-07-21T13:14:50+08:00"
469471
}

packages/service/core/ai/sandbox/application/file.ts

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@
55
*/
66
import type { FileWriteEntry, ISandbox } from '@fastgpt-sdk/sandbox-adapter';
77
import mime from 'mime';
8-
import { pickOutboundAxios } from '../../../../common/api/axios';
8+
import { axios } from '../../../../common/api/axios';
99
import type { SandboxClient } from './runtime/client';
1010
import { getSandboxRuntimeProfile } from '../infrastructure/provider/runtimeProfile';
11+
import { getFileMaxSize } from '../../../../common/file/utils';
12+
import { readStreamToBuffer } from '../../../../common/s3/utils';
13+
import type { Readable } from 'node:stream';
14+
import { getAxiosHeaderValue } from '@fastgpt/global/common/axios/utils';
1115

1216
export type SandboxUrlFile = {
1317
path: string;
@@ -39,6 +43,34 @@ const isWithinSandboxWorkspace = (path: string, workDirectory: string) => {
3943
return path === workspace || path.startsWith(`${workspace}/`);
4044
};
4145

46+
/** 读取准备写入 Sandbox 的远程文件,禁止相对 URL 回环访问本机 API。 */
47+
export const readSandboxUrlFile = async (url: string) => {
48+
if (!/^https?:\/\//i.test(url)) {
49+
throw new Error('Sandbox input file URL must be an absolute HTTP(S) URL');
50+
}
51+
const parsedUrl = new URL(url);
52+
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
53+
throw new Error('Sandbox input file URL must use HTTP(S)');
54+
}
55+
56+
const maxFileSize = getFileMaxSize();
57+
const response = await axios.get<Readable>(url, {
58+
responseType: 'stream',
59+
timeout: 180_000,
60+
maxContentLength: maxFileSize
61+
});
62+
const contentLength = Number(getAxiosHeaderValue(response.headers['content-length']) || 0);
63+
if (contentLength > maxFileSize) {
64+
response.data.destroy();
65+
throw new Error(`File exceeds maximum allowed size (${maxFileSize} bytes)`);
66+
}
67+
return readStreamToBuffer({
68+
stream: response.data,
69+
maxBytes: maxFileSize,
70+
exceededMessage: `File exceeds maximum allowed size (${maxFileSize} bytes)`
71+
});
72+
};
73+
4274
/**
4375
* 将远程 URL 文件写入已存在的 sandbox 实例。
4476
*
@@ -50,14 +82,10 @@ export async function writeUrlFilesToSandbox(sandbox: ISandbox, files: SandboxUr
5082
for (const { path, url } of files) {
5183
if (!path) continue;
5284
writeFileTasks.push(
53-
pickOutboundAxios(url)
54-
.get<ArrayBuffer>(url, {
55-
responseType: 'arraybuffer'
56-
})
57-
.then((response) => ({
58-
path,
59-
data: response.data
60-
}))
85+
readSandboxUrlFile(url).then((data) => ({
86+
path,
87+
data
88+
}))
6189
);
6290
}
6391

0 commit comments

Comments
 (0)