From ff443fc63f09e6b037c652fbe3ba0378b5c910fb Mon Sep 17 00:00:00 2001 From: minorcell Date: Sat, 27 Dec 2025 00:36:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=A7=BB=E9=99=A4=20run=5Fbun=20?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E5=8F=8A=E7=9B=B8=E5=85=B3=E6=96=87=E6=A1=A3?= =?UTF-8?q?=EF=BC=8C=E7=AE=80=E5=8C=96=E5=B7=A5=E5=85=B7=E9=9B=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +- docs/design/memo-cli-ui-design.md | 2 +- docs/tool/run_bun.md | 45 ---- packages/core/src/runtime/prompt.md | 1 - packages/tools/src/index.ts | 3 - packages/tools/src/tools/run_bun.test.ts | 78 ------- packages/tools/src/tools/run_bun.ts | 254 ----------------------- 7 files changed, 2 insertions(+), 384 deletions(-) delete mode 100644 docs/tool/run_bun.md delete mode 100644 packages/tools/src/tools/run_bun.test.ts delete mode 100644 packages/tools/src/tools/run_bun.ts diff --git a/README.md b/README.md index e1639f2..0da2e86 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ memo-cli 提供现代化的终端用户界面,包含以下特性: ## 内置工具概览 - **文件系统**:`read` / `write` / `edit` / `glob` / `grep`,提供偏移、上下文、全局替换等能力。 -- **系统执行**:`bash` 直接运行 Shell;`run_bun` 在沙箱里执行 JS/TS(bubblewrap 或 `sandbox-exec`,可配置网络)。 +- **系统执行**:`bash` 直接运行 Shell。 - **网络获取**:`webfetch` 支持 http/https/data,10 秒超时、512 KB 限制,自动清洗 HTML。 - **辅助工具**:`save_memory`(写入 `~/.memo/memo.md`)、`todo` 管理、`time` 查询。 - **MCP 外部工具**:支持 stdio 或 Streamable HTTP,工具名前会加 `_` 前缀自动注入系统提示词。 @@ -278,7 +278,6 @@ memo-cli/ ## 安全特性 -- `run_bun` 依赖 bubblewrap 或 `sandbox-exec`,并可控制网络访问。 - `webfetch`、`bash` 等工具限制超时时间、输出大小与允许路径,降低风险。 - MCP 工具统一通过配置注入,避免在提示词中硬编码密钥。 diff --git a/docs/design/memo-cli-ui-design.md b/docs/design/memo-cli-ui-design.md index 52a925c..2a3bce4 100644 --- a/docs/design/memo-cli-ui-design.md +++ b/docs/design/memo-cli-ui-design.md @@ -25,7 +25,7 @@ ### 2.3 工具与 MCP -- 内置工具:bash/run_bun/read/write/edit/glob/grep/webfetch/save_memory/time/todo。 +- 内置工具:bash/read/write/edit/glob/grep/webfetch/save_memory/time/todo。 - MCP 外部工具来自 config.mcp*servers,自动注入 prompt,命名为 *。 - 工具返回以文本为主,UI 需做扁平化展示。 diff --git a/docs/tool/run_bun.md b/docs/tool/run_bun.md deleted file mode 100644 index fa6c0c1..0000000 --- a/docs/tool/run_bun.md +++ /dev/null @@ -1,45 +0,0 @@ -# Memo CLI `run_bun` 工具 - -在临时文件中运行任意 Bun (JS/TS) 代码,等价于一个轻量「代码解释器」。 - -## 基本信息 - -- 工具名称:`run_bun` -- 描述:在临时文件中运行 Bun (JS/TS) 代码,支持 top-level await,输出 stdout/stderr 与退出码。 -- 文件:`packages/tools/src/tools/run_bun.ts` -- 确认:否 - -## 参数 - -- `code`(字符串,必填):需要执行的 JS/TS 代码。 - -## 行为 - -- 运行前会创建独立的临时目录(尊重 `TMPDIR`,否则 `os.tmpdir()`),将代码写入 `main.ts`,并在执行后递归删除整个目录。 -- Linux 通过 [bubblewrap (`bwrap`)](https://github.com/containers/bubblewrap) 创建沙箱;macOS 使用 `sandbox-exec` profile。只有该临时目录被绑定为可写,其他系统路径保持只读。 -- 默认 `MEMO_RUN_BUN_ALLOW_NET=0`(禁用网络);设置为 `1` 可打开网络转发。 -- 可以通过 `MEMO_RUN_BUN_SANDBOX='["/path/to/runner","--flag","{{entryFile}}"]'` 自定义沙箱命令;支持 `{{entryFile}}`、`{{runDir}}`、`{{allowNetwork}}` 占位符。 -- 执行命令时自动设置 `TMPDIR=HOME=<临时目录>`、`FORCE_COLOR=0`,避免污染宿主环境。 -- 收集 stdout/stderr 文本及退出码,返回格式: - ``` - exit= - stdout: - - stderr: - - ``` -- 即使出现 runtime error 也会返回(exit 为非 0,stderr 包含错误);只在文件写入/进程创建等异常时标记 `isError=true`。 -- 执行完会尝试删除临时文件(清理失败会被忽略)。 - -## 适用场景 - -- 小块 JS/TS 验证、字符串/数据处理。 -- 调用 npm/bun 内置 API(无需项目依赖)。 -- 验证 top-level await 或 TypeScript 类型提示行为。 - -## 注意 - -- Linux 需要预装 `bwrap`,macOS 依赖系统自带的 `sandbox-exec`;否则需通过 `MEMO_RUN_BUN_SANDBOX` 指定自定义沙箱(命令以 JSON 数组形式配置)。 -- 只能访问环境已有的依赖(未自动安装第三方包)。 -- 网络默认关闭,如确需联网请设置 `MEMO_RUN_BUN_ALLOW_NET=1` 并考虑额外的出口控制。 -- 输出未经截断,长输出可能占用较多 token。\*\*\* diff --git a/packages/core/src/runtime/prompt.md b/packages/core/src/runtime/prompt.md index 42c4070..9eb6fe1 100644 --- a/packages/core/src/runtime/prompt.md +++ b/packages/core/src/runtime/prompt.md @@ -97,7 +97,6 @@ - **read**: `{"file_path": "/abs/...", "offset": 0, "limit": 200}` - **write**: `{"file_path": "/abs/...", "content": "..."}` - **edit**: `{"file_path": "/abs/...", "old_string": "...", "new_string": "...", "replace_all": false}` -- **run_bun**: `{"code": "..."}` - **glob**: `{"pattern": "**/*.ts", "path": "/curr/dir"}` - **grep**: `{"pattern": "string", "path": "/dir", "glob": "*.ts", "-i": false, "-C": 2}` - **webfetch**: `{"url": "..."}` diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index d6845a4..7111da7 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -10,12 +10,9 @@ import { timeTool } from '@memo/tools/tools/time' import { readTool } from '@memo/tools/tools/read' import { writeTool } from '@memo/tools/tools/write' -import { runBunTool } from '@memo/tools/tools/run_bun' - /** 对外暴露的工具集合,供 Agent 通过 tool name 查找。 */ export const TOOLKIT: Record> = { bash: bashTool, - run_bun: runBunTool, read: readTool, write: writeTool, edit: editTool, diff --git a/packages/tools/src/tools/run_bun.test.ts b/packages/tools/src/tools/run_bun.test.ts deleted file mode 100644 index 008c701..0000000 --- a/packages/tools/src/tools/run_bun.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { afterAll, beforeAll, describe, expect, test } from 'bun:test' -import { mkdtemp, readdir, rm } from 'node:fs/promises' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import { flattenText } from '@memo/tools/tools/mcp' -import { runBunTool } from '@memo/tools/tools/run_bun' - -const hasSandbox = (() => { - if (process.env.MEMO_RUN_BUN_SANDBOX) { - return true - } - if (process.platform === 'linux') { - return Boolean(Bun.which('bwrap')) - } - if (process.platform === 'darwin') { - return Boolean(Bun.which('sandbox-exec')) - } - return false -})() - -const describeRunBun = hasSandbox ? describe : describe.skip - -describeRunBun('run_bun tool', () => { - let originalTmpDir: string | undefined - let tempDir: string - - beforeAll(async () => { - originalTmpDir = process.env.TMPDIR - tempDir = await mkdtemp(join(tmpdir(), 'memo-run-bun-test-')) - process.env.TMPDIR = tempDir - }) - - afterAll(async () => { - if (originalTmpDir === undefined) { - delete process.env.TMPDIR - } else { - process.env.TMPDIR = originalTmpDir - } - await rm(tempDir, { recursive: true, force: true }) - }) - - test('supports top-level await and TS syntax', async () => { - const code = ` - type User = { name: string } - const user: User = { name: 'alice' } - const val = await Promise.resolve(42) - console.log(user.name, val) - ` - const beforeFiles = await readdir(tempDir) - expect(beforeFiles.length).toBe(0) - - const result = await runBunTool.execute({ code }) - const text = flattenText(result) - - expect(text).toContain('exit=0') - expect(text).toContain('stdout:\nalice 42') - expect(text).toContain('stderr:\n') - - const afterFiles = await readdir(tempDir) - expect(afterFiles.length).toBe(0) // 临时文件被清理 - }) - - test('propagates stderr and non-zero exit code on runtime error', async () => { - const result = await runBunTool.execute({ - code: ` - console.error("oops") - throw new Error("boom") - `, - }) - const text = flattenText(result) - - expect(text).toMatch(/exit=\d+/) - expect(text).not.toContain('exit=0') - expect(text).toContain('boom') - expect(text).toContain('stderr:') - expect(text).toContain('oops') - }) -}) diff --git a/packages/tools/src/tools/run_bun.ts b/packages/tools/src/tools/run_bun.ts deleted file mode 100644 index ba2d294..0000000 --- a/packages/tools/src/tools/run_bun.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { z } from 'zod' -import type { McpTool } from '@memo/tools/tools/types' -import { textResult } from '@memo/tools/tools/mcp' -import { mkdtemp, realpath, rm } from 'node:fs/promises' -import { join } from 'node:path' -import { tmpdir } from 'node:os' - -const BUN_INPUT_SCHEMA = z - .object({ - code: z.string().min(1, 'code cannot be empty'), - }) - .strict() - -type BunInput = z.infer - -/** - * 通过将代码写入临时文件并运行来执行任意 Bun (JS/TS) 代码。 - * 这充当了 Agent 的 "Code Interpreter"。 - */ -export const runBunTool: McpTool = { - name: 'run_bun', - description: - '在临时文件中运行 Bun (JS/TS) 代码。支持 top-level await。使用 console.log 输出结果。', - inputSchema: BUN_INPUT_SCHEMA, - execute: async ({ code }) => { - const baseTmp = process.env.TMPDIR || tmpdir() - const runDir = await mkdtemp(join(baseTmp, 'memo-run-bun-')) - const tmpFilePath = join(runDir, 'main.ts') - const allowNetwork = process.env.MEMO_RUN_BUN_ALLOW_NET === '1' - - try { - // 将代码写入临时文件 - await Bun.write(tmpFilePath, code) - - const sandboxEnv = createSandboxEnv(runDir, allowNetwork) - const sandbox = await resolveSandbox({ - entryFile: tmpFilePath, - runDir, - env: sandboxEnv, - allowNetwork, - }) - - // 启动 Bun 运行文件(在沙箱内) - const proc = Bun.spawn([sandbox.command, ...sandbox.args], { - stdout: 'pipe', - stderr: 'pipe', - env: sandbox.env, - }) - - const [stdout, stderr] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - ]) - const exitCode = await proc.exited - - return textResult(`exit=${exitCode}\nstdout:\n${stdout}\nstderr:\n${stderr}`) - } catch (err) { - return textResult(`run_bun failed: ${(err as Error).message}`, true) - } finally { - // 清理:尝试删除临时目录 - try { - await rm(runDir, { recursive: true, force: true }) - } catch { - // 忽略清理错误 - } - } - }, -} - -type SandboxContext = { - entryFile: string - runDir: string - env: Record - allowNetwork: boolean -} - -type SandboxSpec = { - command: string - args: string[] - env: Record -} - -const createSandboxEnv = (runDir: string, allowNetwork: boolean): Record => { - const env = sanitizeEnv() - env.TMPDIR = runDir - env.HOME = runDir - env.FORCE_COLOR = '0' - env.MEMO_RUN_BUN_ALLOW_NET = allowNetwork ? '1' : '0' - return env -} - -const sanitizeEnv = (): Record => { - const env: Record = {} - for (const [key, value] of Object.entries(process.env)) { - if (typeof value === 'string') { - env[key] = value - } - } - return env -} - -const resolveSandbox = async (context: SandboxContext): Promise => { - const custom = resolveCustomSandbox(context) - if (custom) { - return custom - } - - if (process.platform === 'linux') { - return resolveLinuxSandbox(context) - } - - if (process.platform === 'darwin') { - return resolveDarwinSandbox(context) - } - - throw new Error( - 'run_bun sandbox is not configured for this platform. Provide MEMO_RUN_BUN_SANDBOX or use Linux/macOS.', - ) -} - -const resolveCustomSandbox = (context: SandboxContext): SandboxSpec | null => { - const raw = process.env.MEMO_RUN_BUN_SANDBOX - if (!raw) { - return null - } - - let parsed: unknown - try { - parsed = JSON.parse(raw) - } catch { - throw new Error('MEMO_RUN_BUN_SANDBOX must be a JSON array of command and args') - } - - if ( - !Array.isArray(parsed) || - parsed.length === 0 || - parsed.some((item) => typeof item !== 'string') - ) { - throw new Error( - 'MEMO_RUN_BUN_SANDBOX must describe command and args, e.g. ["/usr/bin/env","-i"]', - ) - } - - const [command, ...args] = parsed as string[] - const replaced = [command, ...args].map((value) => - applySandboxTemplate(value as string, context), - ) - - return { - command: replaced[0]!, - args: replaced.slice(1), - env: context.env, - } -} - -const resolveLinuxSandbox = ({ - entryFile, - runDir, - env, - allowNetwork, -}: SandboxContext): SandboxSpec => { - const bwrap = Bun.which('bwrap') - if (!bwrap) { - throw new Error( - 'run_bun requires bubblewrap (bwrap) on Linux or MEMO_RUN_BUN_SANDBOX for a custom sandbox runner', - ) - } - - const args = [ - '--die-with-parent', - '--unshare-user', - '--unshare-pid', - '--unshare-uts', - '--unshare-ipc', - '--ro-bind', - '/', - '/', - '--bind', - runDir, - runDir, - '--dev-bind', - '/dev', - '/dev', - '--proc', - '/proc', - '--tmpfs', - '/tmp', - '--chdir', - runDir, - ] - - if (!allowNetwork) { - args.push('--unshare-net') - } - - args.push('bun', 'run', entryFile) - - return { - command: bwrap, - args, - env, - } -} - -const resolveDarwinSandbox = async ({ - entryFile, - runDir, - env, - allowNetwork, -}: SandboxContext): Promise => { - const sandboxExec = Bun.which('sandbox-exec') - if (!sandboxExec) { - throw new Error( - 'sandbox-exec is required on macOS or specify MEMO_RUN_BUN_SANDBOX for a custom sandbox runner', - ) - } - - let resolvedDir = runDir - try { - resolvedDir = await realpath(runDir) - } catch { - // ignore realpath failures - } - - const escapedDir = escapeForSandboxProfile(resolvedDir) - const profileParts = [ - '(version 1)', - '(allow default)', - '(deny file-write*)', - `(allow file-write* (subpath "${escapedDir}"))`, - '(allow file-write* (literal "/dev/null"))', - ] - - if (!allowNetwork) { - profileParts.splice(2, 0, '(deny network*)') - } - - const profile = profileParts.join('\n') - - return { - command: sandboxExec, - args: ['-p', profile, 'bun', 'run', entryFile], - env, - } -} - -const applySandboxTemplate = (value: string, context: SandboxContext): string => - value - .replaceAll('{{entryFile}}', context.entryFile) - .replaceAll('{{runDir}}', context.runDir) - .replaceAll('{{allowNetwork}}', context.allowNetwork ? '1' : '0') - -const escapeForSandboxProfile = (value: string): string => - value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')