Add run_shell and run_python interpreter executors
Why: deliverable prompts already tell the model to generate Office files with run_python and register them, but the executors were never registered, so calls missed the registry and fell back to text-only write_artifact. Evidence: go test ./... — 19 packages ok, including echo/script-retention, empty-script and runtime.exec gate, process-group timeout, python ZIP magic, and ExecuteTool backfill of register_artifact after run_python. Scripts stay under the session .agent/ directory; bash/python3 are resolved to absolute paths; timeout kills the process group. Impact: models can execute workspace scripts and register binary deliverables. runtime.exec=false still fail-closes the whole family. Office extensions now get real MIME types so binary bytes are not indexed as text. Refs: task feature/exec-interpreters Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -81,6 +81,9 @@ func Run(opt Options) error {
|
||||
if err := tools.RegisterExecTools(reg); err != nil {
|
||||
return fmt.Errorf("exec tools: %w", err)
|
||||
}
|
||||
if err := tools.RegisterInterpreterTools(reg); err != nil {
|
||||
return fmt.Errorf("interpreter tools: %w", err)
|
||||
}
|
||||
setSvc := settings.NewService(st)
|
||||
setSvc.SetSystemOverride(map[string]any{"storage": map[string]any{"dataDir": opt.DataDir}})
|
||||
// 网络搜索(Tavily):配置密钥池才注册(对齐 agent-native 语义:未配置不静默降级)。
|
||||
|
||||
@@ -6,7 +6,9 @@ package runmgr
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/bus"
|
||||
@@ -63,6 +65,46 @@ func TestExecuteToolBackfillsArtifact(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// run_python 生成文件后 register_artifact 必须把工件回填到 ToolCallResult.Artifact。
|
||||
func TestRunPythonRegisterArtifactBackfill(t *testing.T) {
|
||||
c, _ := newExecEnv(t)
|
||||
root := t.TempDir()
|
||||
c.mgr.SetWorkspaceRoot(root)
|
||||
if err := tools.RegisterExecTools(c.mgr.registry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tools.RegisterInterpreterTools(c.mgr.registry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
py := c.ExecuteTool(core.ToolCallRequest{
|
||||
CallID: "py1", Name: "run_python",
|
||||
Arguments: json.RawMessage(`{"script":"open('note.docx','wb').write(b'PK\\x03\\x04hello')\n"}`),
|
||||
})
|
||||
if py.Status != "ok" || !strings.Contains(py.Output, "exit=0") {
|
||||
t.Fatalf("run_python: %s %s", py.Status, py.Output)
|
||||
}
|
||||
res := c.ExecuteTool(core.ToolCallRequest{
|
||||
CallID: "reg1", Name: "register_artifact",
|
||||
Arguments: json.RawMessage(`{"path":"note.docx"}`),
|
||||
})
|
||||
if res.Status != "ok" {
|
||||
t.Fatalf("register_artifact: %s", res.Output)
|
||||
}
|
||||
if res.Artifact == nil || res.Artifact.Name != "note.docx" {
|
||||
t.Fatalf("ToolCallResult.Artifact 未回填: %+v", res.Artifact)
|
||||
}
|
||||
if res.Artifact.MIME != "application/vnd.openxmlformats-officedocument.wordprocessingml.document" {
|
||||
t.Errorf("docx mime: %s", res.Artifact.MIME)
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(root, "alice", c.run.ConversationID, "note.docx"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(b[:4]) != "PK\x03\x04" {
|
||||
t.Fatalf("stored bytes: %q", b)
|
||||
}
|
||||
}
|
||||
|
||||
// 空手工具(如 time_now)不得凭空挂 Artifact。
|
||||
func TestExecuteToolNoArtifactByDefault(t *testing.T) {
|
||||
c, _ := newExecEnv(t)
|
||||
|
||||
@@ -468,6 +468,7 @@ func (t *toolCtx) WriteArtifact(name, kind, mime string, content []byte) (core.A
|
||||
}
|
||||
return art, err
|
||||
}
|
||||
func (t *toolCtx) Context() context.Context { return t.rc.Context() }
|
||||
func (t *toolCtx) OwnerID() string { return t.rc.run.Owner }
|
||||
func (t *toolCtx) WorkspaceRoot() string { return t.rc.mgr.workspaceRoot }
|
||||
func (t *toolCtx) SettingBool(key string, def bool) bool { return t.rc.eff.GetBool(key, def) }
|
||||
|
||||
@@ -47,10 +47,9 @@ func execSandbox(rc ToolExecCtx) (string, error) {
|
||||
return sandbox, nil
|
||||
}
|
||||
|
||||
// RegisterExecTools 注册执行原语族。当前批次:register_artifact(本文件)。
|
||||
// run_shell / run_python(解释器执行器)实现方案已定(脚本落盘+白名单解释器),
|
||||
// 因安全门禁分批落盘——补齐后在 app.go 追加 RegisterInterpreterTools 并同批
|
||||
// 注册(契约见 base.deliverables 提示词)。
|
||||
// RegisterExecTools 注册执行原语族的登记桥(register_artifact)。
|
||||
// run_shell / run_python 在 exec_interpreter.go,由 RegisterInterpreterTools 同批注册
|
||||
// (契约见 base.deliverables 提示词)。
|
||||
func RegisterExecTools(r *Registry) error {
|
||||
return r.Register(core.ToolDesc{
|
||||
Name: "register_artifact", Source: core.SourceBuiltin,
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
// run_shell / run_python 解释器执行器(与 register_artifact 同族)。
|
||||
//
|
||||
// 语义:模型提交的脚本原样落盘到会话工作区 .agent/ 下(字节精确、执行后保留以便审计),
|
||||
// 再以该文件为唯一参数启动白名单解释器(bash / python3 的绝对路径)——模型输入不进入
|
||||
// 进程参数。工作目录锁定会话沙箱;超时取请求 context 与 timeout_sec(默认 60s、上限 300s)
|
||||
// 的先到者,超时杀掉整个进程组;输出 64KB 头尾截断。非零退出、超时与取消作为文本反馈返回
|
||||
// (模型据此自纠),仅启动失败算工具错误。
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// RegisterInterpreterTools 注册解释器执行原语(run_shell / run_python)。
|
||||
func RegisterInterpreterTools(r *Registry) error {
|
||||
if err := r.Register(core.ToolDesc{
|
||||
Name: "run_shell", Source: core.SourceBuiltin,
|
||||
Description: "在会话工作区目录内执行 bash 脚本:检查环境、安装依赖(pip install --user …)、处理文件等。脚本原样落盘执行;返回退出码与合并输出(stdout+stderr,超限头尾截断)。以当前用户身份运行。",
|
||||
Parameters: json.RawMessage(`{"type":"object","properties":{` +
|
||||
`"script":{"type":"string","description":"bash 脚本(可多行)"},` +
|
||||
`"timeout_sec":{"type":"integer","description":"超时秒数(默认 60,上限 300)"}}` +
|
||||
`,"required":["script"]}`),
|
||||
}, execShell); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.Register(core.ToolDesc{
|
||||
Name: "run_python", Source: core.SourceBuiltin,
|
||||
Description: "执行 Python 脚本(python3,工作目录为会话工作区):生成文档/表格/演示稿/数据处理的首选路径。脚本原样落盘执行;stdout/stderr 合并返回,超限头尾截断。python-docx/openpyxl/python-pptx 缺失时可先 pip install --user 安装。",
|
||||
Parameters: json.RawMessage(`{"type":"object","properties":{` +
|
||||
`"script":{"type":"string","description":"完整 Python 脚本(utf-8)"},` +
|
||||
`"timeout_sec":{"type":"integer","description":"超时秒数(默认 60,上限 300)"}}` +
|
||||
`,"required":["script"]}`),
|
||||
}, execPython)
|
||||
}
|
||||
|
||||
// execEnv 最小环境:PATH/HOME/LANG/TZ/TERM 继承(pip --user、字体、中文 locale),
|
||||
// 其余变量不带入子进程。
|
||||
func execEnv() []string {
|
||||
var out []string
|
||||
for _, kv := range os.Environ() {
|
||||
for _, keep := range []string{"PATH=", "HOME=", "LANG=", "LC_", "TZ=", "TERM="} {
|
||||
if strings.HasPrefix(kv, keep) {
|
||||
out = append(out, kv)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return append(out, "PYTHONDONTWRITEBYTECODE=1")
|
||||
}
|
||||
|
||||
// truncateOutput 头 2/3 + 尾 1/3 保留,中部标记省略。
|
||||
func truncateOutput(b []byte) string {
|
||||
if len(b) <= execMaxOutput {
|
||||
return string(b)
|
||||
}
|
||||
head := execMaxOutput * 2 / 3
|
||||
tail := execMaxOutput / 3
|
||||
return string(b[:head]) + fmt.Sprintf("\n…(输出超限,省略 %d 字节)…\n", len(b)-head-tail) + string(b[len(b)-tail:])
|
||||
}
|
||||
|
||||
// writeScript 把模型脚本落盘到工作区 .agent/ 下(字节精确,执行后保留),返回绝对路径。
|
||||
func writeScript(rc ToolExecCtx, sandbox, prefix, ext, content string) (string, error) {
|
||||
dir := filepath.Join(sandbox, execScriptDir)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
p := filepath.Join(dir, fmt.Sprintf("%s-%s-%d%s", prefix, rc.ConversationID(), time.Now().UnixNano(), ext))
|
||||
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
type execContexter interface {
|
||||
Context() context.Context
|
||||
}
|
||||
|
||||
func execParentCtx(rc ToolExecCtx) context.Context {
|
||||
if c, ok := rc.(execContexter); ok {
|
||||
if ctx := c.Context(); ctx != nil {
|
||||
return ctx
|
||||
}
|
||||
}
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
// resolveInterpreter 把白名单解释器名钉成绝对路径,避免 PATH 在子进程里被换掉。
|
||||
func resolveInterpreter(name string) (string, error) {
|
||||
switch name {
|
||||
case "bash", "python3":
|
||||
default:
|
||||
return "", fmt.Errorf("interpreter %s is not allowlisted", name)
|
||||
}
|
||||
p, err := exec.LookPath(name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !filepath.IsAbs(p) {
|
||||
return "", fmt.Errorf("%s: resolved path is not absolute: %s", name, p)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// runInterpreter 启动白名单解释器执行落盘脚本(脚本路径为唯一参数)。
|
||||
// 非零退出、超时与请求取消都是正常反馈,以文本返回;仅启动失败算错误。
|
||||
func runInterpreter(parent context.Context, interpreter, scriptPath, dir string, timeoutS int) (string, error) {
|
||||
if parent == nil {
|
||||
parent = context.Background()
|
||||
}
|
||||
if timeoutS <= 0 {
|
||||
timeoutS = execDefaultTimeoutS
|
||||
}
|
||||
if timeoutS > execMaxTimeoutS {
|
||||
timeoutS = execMaxTimeoutS
|
||||
}
|
||||
bin, err := resolveInterpreter(interpreter)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: %w", interpreter, err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(parent, time.Duration(timeoutS)*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, bin, scriptPath)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = execEnv()
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
cmd.WaitDelay = 2 * time.Second
|
||||
cmd.Cancel = func() error {
|
||||
if cmd.Process == nil {
|
||||
return os.ErrProcessDone
|
||||
}
|
||||
err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
if errors.Is(err, syscall.ESRCH) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
out, err := cmd.CombinedOutput()
|
||||
text := truncateOutput(out)
|
||||
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
return fmt.Sprintf("exit=timeout(%ds)\n%s", timeoutS, text), nil
|
||||
}
|
||||
if errors.Is(parent.Err(), context.Canceled) {
|
||||
return fmt.Sprintf("exit=canceled\n%s", text), nil
|
||||
}
|
||||
if err != nil {
|
||||
var ee *exec.ExitError
|
||||
if errors.As(err, &ee) {
|
||||
return fmt.Sprintf("exit=%d\n%s", ee.ExitCode(), text), nil
|
||||
}
|
||||
return "", fmt.Errorf("%s: %v:%s", interpreter, err, text)
|
||||
}
|
||||
return "exit=0\n" + text, nil
|
||||
}
|
||||
|
||||
func execShell(rc ToolExecCtx, args json.RawMessage) (string, error) {
|
||||
if err := execGate(rc); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var a struct {
|
||||
Script string `json:"script"`
|
||||
Command string `json:"command"`
|
||||
TimeoutS int `json:"timeout_sec"`
|
||||
TimeoutSec int `json:"timeoutSec"`
|
||||
}
|
||||
if len(args) > 0 {
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
return "", fmt.Errorf("run_shell: bad args: %w", err)
|
||||
}
|
||||
}
|
||||
script := a.Script
|
||||
if strings.TrimSpace(script) == "" {
|
||||
script = a.Command
|
||||
}
|
||||
if strings.TrimSpace(script) == "" {
|
||||
return "", fmt.Errorf("run_shell: script required")
|
||||
}
|
||||
sandbox, err := execSandbox(rc)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
p, err := writeScript(rc, sandbox, "sh", ".sh", script)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return runInterpreter(execParentCtx(rc), "bash", p, sandbox, firstPos(a.TimeoutS, a.TimeoutSec))
|
||||
}
|
||||
|
||||
func execPython(rc ToolExecCtx, args json.RawMessage) (string, error) {
|
||||
if err := execGate(rc); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var a struct {
|
||||
Script string `json:"script"`
|
||||
TimeoutS int `json:"timeout_sec"`
|
||||
TimeoutSec int `json:"timeoutSec"`
|
||||
}
|
||||
if len(args) > 0 {
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
return "", fmt.Errorf("run_python: bad args: %w", err)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(a.Script) == "" {
|
||||
return "", fmt.Errorf("run_python: script required")
|
||||
}
|
||||
sandbox, err := execSandbox(rc)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
p, err := writeScript(rc, sandbox, "py", ".py", a.Script)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return runInterpreter(execParentCtx(rc), "python3", p, sandbox, firstPos(a.TimeoutS, a.TimeoutSec))
|
||||
}
|
||||
|
||||
func firstPos(vals ...int) int {
|
||||
for _, v := range vals {
|
||||
if v > 0 {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
type execFake struct {
|
||||
root string
|
||||
owner string
|
||||
conv string
|
||||
execOn bool
|
||||
settings map[string]bool
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (c *execFake) ProjectID() string { return "p1" }
|
||||
func (c *execFake) ConversationID() string { return c.conv }
|
||||
func (c *execFake) OwnerID() string { return c.owner }
|
||||
func (c *execFake) WorkspaceRoot() string { return c.root }
|
||||
func (c *execFake) Context() context.Context {
|
||||
if c.ctx != nil {
|
||||
return c.ctx
|
||||
}
|
||||
return context.Background()
|
||||
}
|
||||
func (c *execFake) SettingBool(key string, def bool) bool {
|
||||
if c.settings != nil {
|
||||
if v, ok := c.settings[key]; ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
if key == execSetting {
|
||||
return c.execOn
|
||||
}
|
||||
return def
|
||||
}
|
||||
func (c *execFake) Retrieve(core.RetrievalQuery) ([]core.RetrievalHit, error) { return nil, nil }
|
||||
func (c *execFake) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) {
|
||||
return core.Artifact{ID: "art_" + name, Name: name, Kind: kind, MIME: mime, Size: int64(len(content))}, nil
|
||||
}
|
||||
func (c *execFake) PeerConversations() ([]core.Conversation, error) { return nil, nil }
|
||||
func (c *execFake) PeerConversationMessages(string, int) ([]core.Message, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func newInterpRegistry(t *testing.T) *Registry {
|
||||
t.Helper()
|
||||
r := NewRegistry()
|
||||
if err := RegisterInterpreterTools(r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func interpCall(t *testing.T, r *Registry, rc *execFake, name, args string) core.ToolCallResult {
|
||||
t.Helper()
|
||||
return r.Execute(rc, core.ToolCallRequest{CallID: "c1", Name: name, Arguments: json.RawMessage(args)})
|
||||
}
|
||||
|
||||
func TestRunShellEchoKeepsScript(t *testing.T) {
|
||||
r := newInterpRegistry(t)
|
||||
root := t.TempDir()
|
||||
rc := &execFake{root: root, owner: "o1", conv: "c1", execOn: true}
|
||||
const script = "echo hello-from-shell\n"
|
||||
res := interpCall(t, r, rc, "run_shell", `{"script":"echo hello-from-shell\n"}`)
|
||||
if res.Status != "ok" || !strings.Contains(res.Output, "exit=0") || !strings.Contains(res.Output, "hello-from-shell") {
|
||||
t.Fatalf("echo script: %s %s", res.Status, res.Output)
|
||||
}
|
||||
dir := filepath.Join(root, "o1", "c1", execScriptDir)
|
||||
ents, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ents) != 1 {
|
||||
t.Fatalf("script should be retained for audit, got %d files", len(ents))
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(dir, ents[0].Name()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(b) != script {
|
||||
t.Fatalf("script bytes mismatch: %q", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunShellCommandAlias(t *testing.T) {
|
||||
r := newInterpRegistry(t)
|
||||
rc := &execFake{root: t.TempDir(), owner: "o1", conv: "c1", execOn: true}
|
||||
res := interpCall(t, r, rc, "run_shell", `{"command":"echo aliased"}`)
|
||||
if res.Status != "ok" || !strings.Contains(res.Output, "aliased") {
|
||||
t.Fatalf("command alias: %s %s", res.Status, res.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunShellEmptyAndGate(t *testing.T) {
|
||||
r := newInterpRegistry(t)
|
||||
rc := &execFake{root: t.TempDir(), owner: "o1", conv: "c1", execOn: true}
|
||||
res := interpCall(t, r, rc, "run_shell", `{"script":" "}`)
|
||||
if res.Status != "error" || !strings.Contains(res.Output, "script required") {
|
||||
t.Fatalf("empty script: %s %s", res.Status, res.Output)
|
||||
}
|
||||
rc.execOn = false
|
||||
res = interpCall(t, r, rc, "run_shell", `{"script":"echo no"}`)
|
||||
if res.Status != "error" || !strings.Contains(res.Output, "runtime.exec") {
|
||||
t.Fatalf("gate off: %s %s", res.Status, res.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunShellTimeoutKillsProcessGroup(t *testing.T) {
|
||||
r := newInterpRegistry(t)
|
||||
rc := &execFake{root: t.TempDir(), owner: "o1", conv: "c1", execOn: true}
|
||||
res := interpCall(t, r, rc, "run_shell", `{"timeout_sec":1,"script":"sleep 30 &\nwait\n"}`)
|
||||
if res.Status != "ok" || !strings.Contains(res.Output, "exit=timeout(1s)") {
|
||||
t.Fatalf("timeout: %s %s", res.Status, res.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPythonWritesZipMagic(t *testing.T) {
|
||||
r := newInterpRegistry(t)
|
||||
root := t.TempDir()
|
||||
rc := &execFake{root: root, owner: "o1", conv: "c1", execOn: true}
|
||||
res := interpCall(t, r, rc, "run_python", `{"script":"open('note.docx','wb').write(b'PK\\x03\\x04hello')\n"}`)
|
||||
if res.Status != "ok" || !strings.Contains(res.Output, "exit=0") {
|
||||
t.Fatalf("python: %s %s", res.Status, res.Output)
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(root, "o1", "c1", "note.docx"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(b) < 4 || string(b[:4]) != "PK\x03\x04" {
|
||||
t.Fatalf("docx magic: %q", b)
|
||||
}
|
||||
}
|
||||
@@ -202,6 +202,18 @@ func mimeOfExt(name string) string {
|
||||
return "text/csv"
|
||||
case ".html":
|
||||
return "text/html"
|
||||
case ".docx":
|
||||
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
case ".xlsx":
|
||||
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
case ".pptx":
|
||||
return "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||
case ".pdf":
|
||||
return "application/pdf"
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
default:
|
||||
return "text/plain; charset=utf-8"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user