Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ config.local.yaml
data/
.cache/
.neocode/projects/**/.transcripts/
.gocache/
.gomodcache/

# Editor/IDE
.idea/
Expand All @@ -43,6 +45,7 @@ workspace.xml
.claude/
.windsurf/
.codebuddy/
.agents/
# VitePress / frontend build artifacts
www/.vitepress/cache/
www/.vitepress/dist/
Expand Down
17 changes: 11 additions & 6 deletions docs/gateway-rpc-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,11 +176,12 @@ type RunParams struct {
}
```

- 多模态图片约束
- 多模态附件约束
- `type=image` 时 `media.mime_type` 必填。
- `media.uri` 与 `media.asset_id` 必须二选一,不能同时为空或同时提供。
- `media.uri` 仅用于后端可读取的本地路径;Web 浏览器上传图片应先通过 `POST /api/session-assets` 保存,再在 `gateway.run` 中使用 `media.asset_id` 引用。
- `media.uri` 仅用于后端可读取的本地路径;Web 浏览器上传图片或文本应先通过 `POST /api/session-assets` 保存,再在 `gateway.run` 中使用 `media.asset_id` 引用。
- `asset_id` 必须属于当前 `session_id`,不存在或跨 session 引用会在 runtime 输入准备阶段失败。
- 文本附件(如 `.md`、`.json`、`.csv`)使用 `type=image` + 真实文本 mime 表达,runtime 端按 `session.TextAssetWhitelist` 自动判定并内联为 user message 的 text part;无需新增 `type` 字段。详见 issue #701。

- Response Schema:
- Success(受理即返回):
Expand Down Expand Up @@ -242,13 +243,15 @@ type RunParams struct {
- Content-Type: `multipart/form-data`
- Fields:
- `session_id`: 目标会话 ID,必填。
- `file`: 图片文件,必填。
- `file`: 图片或文本文件,必填。
- Server-side validation:
- 仅接受 `image/png`、`image/jpeg`、`image/webp`。
- 接受 `image/png`、`image/jpeg`、`image/webp`(按文件头嗅探)。
- 同时接受会话侧文本资产白名单内的扩展名(`.txt`、`.md`、`.json`、`.yaml`、`.yml`、`.csv`)与对应 MIME(`text/plain`、`text/markdown`、`application/json`、`text/yaml`、`application/x-yaml`、`text/csv`)。
- 文本资产额外校验 UTF-8,非 UTF-8 内容返回 `415`。
- MIME 以服务端文件头检测结果为准,不信任浏览器声明。
- 空文件返回 `400`。
- 超过 `MaxSessionAssetBytes` 返回 `413`。
- 非图片或不支持类型返回 `415`。
- 超过 `MaxSessionAssetBytes`(图片)或 `MaxTextAssetBytes`(文本)返回 `413`。
- 不在任一白名单内的类型返回 `415`。
- 未认证返回 `401`,Origin/CORS 或 ACL 拒绝返回 `403`。
- 工作区不存在返回 `404 workspace not found`;目标 session 不在该工作区返回 `404 session not found`。
- Response:
Expand All @@ -262,6 +265,8 @@ type RunParams struct {
}
```

文本附件上传成功后,runtime 会在 `PrepareUserInput` 阶段按 `session.TextAssetWhitelist` 命中后自动读取并内联为 user message 的 `text` content part(带文件名边界 + 可选截断提示),Provider 层不感知"文件"概念。配置项 `runtime.assets.text_asset_enabled`(默认 `true`)可关闭该行为,关闭后文本附件会作为普通附件原样提交。详见 `docs/runtime-provider-event-flow.md` 与 issue #701。

### GET /api/session-assets/{session_id}/{asset_id}

- Auth Required: Yes(`Authorization: Bearer <token>`)
Expand Down
6 changes: 6 additions & 0 deletions docs/guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ runtime:
assets:
max_session_asset_bytes: 20971520
max_session_assets_total_bytes: 20971520
text_asset_enabled: true
max_text_asset_bytes: 262144 # 256 KiB
max_text_asset_chars: 250000 # ~25 万 UTF-8 字符

tools:
webfetch:
Expand Down Expand Up @@ -112,6 +115,9 @@ context:
| `runtime.hooks.items` | user hooks 列表;支持 `builtin/sync` 与 `http/observe` 两种子类型 |
| `runtime.assets.max_session_asset_bytes` | 单个 `session_asset` 最大原始字节数,默认 `20971520`(20 MiB);`0` 或未配置时回退默认值 |
| `runtime.assets.max_session_assets_total_bytes` | 单次请求可携带的 `session_asset` 原始总字节上限,默认 `20971520`(20 MiB);`0` 或未配置时回退默认值 |
| `runtime.assets.text_asset_enabled` | 是否把文本类 asset 在提交会话前内联为 text part,默认 `true`;关闭后文本 asset 走原图片路径(回滚开关) |
| `runtime.assets.max_text_asset_bytes` | 单个文本 asset 最大字节数,默认 `262144`(256 KiB),硬上限 `4 MiB`;超过保存时返回 413 |
| `runtime.assets.max_text_asset_chars` | 单个文本 asset 在 UTF-8 解码后的最大字符数,默认 `250000`,硬上限 `4_000_000`;超过时截断并保留截断提示 |

### `runtime.hooks.items` 字段约束

Expand Down
65 changes: 64 additions & 1 deletion internal/config/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ type RuntimeConfig struct {
type RuntimeAssetsConfig struct {
MaxSessionAssetBytes int64 `yaml:"max_session_asset_bytes,omitempty"`
MaxSessionAssetsTotalBytes int64 `yaml:"max_session_assets_total_bytes,omitempty"`
// TextAssetEnabled 控制是否把文本类 asset 在提交会话前内联为 text part;关闭时文本 asset
// 仅作为图像风格的会话附件存在(保持向后兼容,便于回滚)。
TextAssetEnabled *bool `yaml:"text_asset_enabled,omitempty"`
// MaxTextAssetBytes 限制单个文本 asset 的字节上限(保存与读取都受此约束)。
MaxTextAssetBytes int64 `yaml:"max_text_asset_bytes,omitempty"`
// MaxTextAssetChars 限制单个文本 asset 在 UTF-8 解码后允许保留的最大字符数。
MaxTextAssetChars int `yaml:"max_text_asset_chars,omitempty"`
}

// defaultRuntimeConfig 返回 runtime 配置的静态默认值。
Expand All @@ -40,9 +47,13 @@ func defaultRuntimeConfig() RuntimeConfig {

// defaultRuntimeAssetsConfig 返回 runtime 附件限制配置默认值。
func defaultRuntimeAssetsConfig() RuntimeAssetsConfig {
enabled := true
return RuntimeAssetsConfig{
MaxSessionAssetBytes: session.MaxSessionAssetBytes,
MaxSessionAssetsTotalBytes: provider.MaxSessionAssetsTotalBytes,
TextAssetEnabled: &enabled,
MaxTextAssetBytes: session.DefaultMaxTextAssetBytes,
MaxTextAssetChars: session.DefaultMaxTextAssetChars,
}
}

Expand Down Expand Up @@ -107,9 +118,24 @@ func (c RuntimeConfig) ResolveRequestAssetBudget() provider.RequestAssetBudget {
return c.Assets.ResolveRequestAssetBudget()
}

// ResolveTextAssetPolicy 归一化 runtime 文本附件策略并施加代码硬上限兜底。
func (c RuntimeConfig) ResolveTextAssetPolicy() session.TextAssetPolicy {
return c.Assets.ResolveTextAssetPolicy()
}

// IsTextAssetEnabled 返回当前 runtime 文本附件内联开关。
func (c RuntimeConfig) IsTextAssetEnabled() bool {
return c.Assets.IsTextAssetEnabled()
}

// Clone 复制附件限制配置,避免调用方共享可变状态。
func (c RuntimeAssetsConfig) Clone() RuntimeAssetsConfig {
return c
out := c
if c.TextAssetEnabled != nil {
enabled := *c.TextAssetEnabled
out.TextAssetEnabled = &enabled
}
return out
}

// ApplyDefaults 在配置缺失、为零或非法时回填附件限制默认值。
Expand All @@ -123,6 +149,20 @@ func (c *RuntimeAssetsConfig) ApplyDefaults(defaults RuntimeAssetsConfig) {
if c.MaxSessionAssetsTotalBytes <= 0 {
c.MaxSessionAssetsTotalBytes = defaults.MaxSessionAssetsTotalBytes
}
if c.TextAssetEnabled == nil {
// nil 视为 true,与 IsTextAssetEnabled() 的 nil-as-true 语义对齐。
enabled := true
if defaults.TextAssetEnabled != nil {
enabled = *defaults.TextAssetEnabled
}
c.TextAssetEnabled = &enabled
}
if c.MaxTextAssetBytes <= 0 {
c.MaxTextAssetBytes = defaults.MaxTextAssetBytes
}
if c.MaxTextAssetChars <= 0 {
c.MaxTextAssetChars = defaults.MaxTextAssetChars
}
}

// Validate 校验附件限制配置是否满足最小约束;0 表示使用默认值,仅禁止负数。
Expand All @@ -133,9 +173,23 @@ func (c RuntimeAssetsConfig) Validate() error {
if c.MaxSessionAssetsTotalBytes < 0 {
return errors.New("runtime.assets.max_session_assets_total_bytes must be greater than or equal to 0")
}
if c.MaxTextAssetBytes < 0 {
return errors.New("runtime.assets.max_text_asset_bytes must be greater than or equal to 0")
}
if c.MaxTextAssetChars < 0 {
return errors.New("runtime.assets.max_text_asset_chars must be greater than or equal to 0")
}
return nil
}

// IsTextAssetEnabled 返回文本类 asset 内联开关;nil 视为 true。
func (c RuntimeAssetsConfig) IsTextAssetEnabled() bool {
if c.TextAssetEnabled == nil {
return true
}
return *c.TextAssetEnabled
}

// ResolveSessionAssetPolicy 归一化附件存储策略并应用代码硬上限。
func (c RuntimeAssetsConfig) ResolveSessionAssetPolicy() session.AssetPolicy {
return session.NormalizeAssetPolicy(session.AssetPolicy{
Expand All @@ -150,3 +204,12 @@ func (c RuntimeAssetsConfig) ResolveRequestAssetBudget() provider.RequestAssetBu
MaxSessionAssetsTotalBytes: c.MaxSessionAssetsTotalBytes,
}, assetPolicy.MaxSessionAssetBytes)
}

// ResolveTextAssetPolicy 归一化文本附件策略并应用代码硬上限。
func (c RuntimeAssetsConfig) ResolveTextAssetPolicy() session.TextAssetPolicy {
return session.NormalizeTextAssetPolicy(session.TextAssetPolicy{
Whitelist: session.DefaultTextAssetWhitelist(),
MaxTextAssetBytes: c.MaxTextAssetBytes,
MaxTextAssetChars: c.MaxTextAssetChars,
})
}
18 changes: 18 additions & 0 deletions internal/config/runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,24 @@ func TestRuntimeConfigCloneAndDefaults(t *testing.T) {
}
}

// TestRuntimeAssetsConfigApplyDefaultsNilAsTrue 验证当 defaults.TextAssetEnabled 为 nil 时,
// ApplyDefaults 将 TextAssetEnabled 设为 true,与 IsTextAssetEnabled() 的 nil-as-true 语义对齐。
func TestRuntimeAssetsConfigApplyDefaultsNilAsTrue(t *testing.T) {
t.Parallel()

var zero RuntimeAssetsConfig
zero.ApplyDefaults(RuntimeAssetsConfig{})
if zero.TextAssetEnabled == nil {
t.Fatal("expected TextAssetEnabled to be non-nil after ApplyDefaults")
}
if !*zero.TextAssetEnabled {
t.Fatalf("expected TextAssetEnabled default true, got false")
}
if !zero.IsTextAssetEnabled() {
t.Fatalf("expected IsTextAssetEnabled() true, got false")
}
}

func TestRuntimeConfigValidate(t *testing.T) {
t.Parallel()

Expand Down
60 changes: 58 additions & 2 deletions internal/gateway/network_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"strings"
"sync"
"time"
"unicode/utf8"

"github.com/prometheus/client_golang/prometheus/promhttp"
"golang.org/x/net/websocket"
Expand Down Expand Up @@ -435,14 +436,18 @@ func (s *NetworkServer) handleSessionAssetUpload(writer http.ResponseWriter, req
return
}

file, _, err := request.FormFile("file")
file, fileHeader, err := request.FormFile("file")
if err != nil {
writeJSONResponse(writer, http.StatusBadRequest, map[string]string{"error": "file is required"})
return
}
defer func() {
_ = file.Close()
}()
fileName := ""
if fileHeader != nil {
fileName = strings.TrimSpace(fileHeader.Filename)
}

payload, err := io.ReadAll(io.LimitReader(file, limit+1))
if err != nil {
Expand All @@ -460,7 +465,18 @@ func (s *NetworkServer) handleSessionAssetUpload(writer http.ResponseWriter, req

mimeType := detectAllowedUploadImageMime(payload)
if mimeType == "" {
writeJSONResponse(writer, http.StatusUnsupportedMediaType, map[string]string{"error": "unsupported image type"})
// 文本附件走白名单嗅探:先按声明/扩展名匹配,再做 UTF-8 校验。
mimeType = detectAllowedUploadTextMime(payload, fileName)
}
if mimeType == "" {
writeJSONResponse(writer, http.StatusUnsupportedMediaType, map[string]string{"error": "unsupported asset type"})
return
}

// 文本附件走更严格的字节上限(256 KiB),在网关层提前拦截,避免大文本完整读入内存后才在 SaveAsset 深处被拒。
if agentsession.DefaultTextAssetWhitelist().LookupByMime(mimeType) &&
Comment thread
wynxing marked this conversation as resolved.
Outdated
int64(len(payload)) > agentsession.DefaultMaxTextAssetBytes {
writeJSONResponse(writer, http.StatusRequestEntityTooLarge, map[string]string{"error": "text asset exceeds size limit"})
return
}

Expand Down Expand Up @@ -626,6 +642,46 @@ func detectAllowedUploadImageMime(payload []byte) string {
}
}

// detectAllowedUploadTextMime 按会话侧文本资产白名单探测上传文件 MIME。
// 流程:先按文件扩展名查 mime;若未命中则尝试从 payload 内容头推断(仅对纯文本类文件)。
// 任一环节命中后必须再次校验 payload 是否为合法 UTF-8,非 UTF-8 返回空。
// 与 detectAllowedUploadImageMime 并列,互不冲突。
func detectAllowedUploadTextMime(payload []byte, fileName string) string {
if len(payload) == 0 {
return ""
}
whitelist := agentsession.DefaultTextAssetWhitelist()
if whitelist.IsEmpty() {
return ""
}
mimeType := whitelist.LookupByExtension(fileName)
if mimeType == "" {
// 用 http.DetectContentType 做粗略内容嗅探;命中 text/* 即认为是文本。
probe := payload
if len(probe) > 512 {
probe = probe[:512]
}
detected := strings.ToLower(strings.TrimSpace(http.DetectContentType(probe)))
if !strings.HasPrefix(detected, "text/") {
return ""
}
// http.DetectContentType 对纯文本会返回 "text/plain; charset=utf-8",
// 需剥离 "; charset=..." 参数后再与白名单对比,否则永远匹配失败。
mediaType := strings.TrimSpace(strings.SplitN(detected, ";", 2)[0])
// 仅接受白名单内的 mime,避免被任意 text/* 通过。
if !whitelist.LookupByMime(mediaType) {
return ""
}
mimeType = mediaType
}
// UTF-8 校验:文本资产进入 runtime 后会被读取并按 UTF-8 解码,非 UTF-8 会立刻失败。
// 提前在网关层拒绝,避免无效上传占用存储。
if !utf8.Valid(payload) {
return ""
}
return mimeType
}

// parseSessionAssetPath 从 /api/session-assets/{session_id}/{asset_id} 提取路径参数。
func parseSessionAssetPath(rawPath string) (string, string, bool) {
cleanPath := path.Clean("/" + strings.TrimSpace(rawPath))
Expand Down
Loading
Loading