hunian commited on
Commit ·
c60f42b
1
Parent(s): 861c406
feat: Implement agentfs-just-bash backend with internal and public session management
Browse files- Added InternalSandboxSession and public SandboxSession implementations in agentfs-sandbox/session.ts.
- Introduced path resolution utilities for AgentFS data layout in agentfs/paths.ts.
- Refactored sandbox.ts to utilize the new session management structure and path utilities.
- Updated docker-entrypoint.mjs to enhance logging for workflow configurations.
- .env.example +22 -9
- Dockerfile +6 -1
- README.md +29 -20
- agent/agent.ts +33 -7
- agent/channels/agentfs.ts +147 -169
- agent/connections/message.ts +33 -3
- agent/lib/agentfs-debug.ts +55 -508
- agent/lib/agentfs-debug/browse.ts +110 -0
- agent/lib/agentfs-debug/checkpoint.ts +220 -0
- agent/lib/agentfs-debug/constants.ts +15 -0
- agent/lib/agentfs-debug/errors.ts +59 -0
- agent/lib/agentfs-debug/open.ts +114 -0
- agent/lib/agentfs-debug/paths.ts +93 -0
- agent/lib/agentfs-debug/sessions.ts +46 -0
- agent/lib/agentfs-debug/types.ts +34 -0
- agent/lib/agentfs-sandbox/backend.ts +189 -0
- agent/lib/agentfs-sandbox/host.ts +246 -0
- agent/lib/agentfs-sandbox/metadata.ts +42 -0
- agent/lib/agentfs-sandbox/pad-fs.ts +30 -0
- agent/lib/agentfs-sandbox/process.ts +88 -0
- agent/lib/agentfs-sandbox/prune.ts +70 -0
- agent/lib/agentfs-sandbox/session.ts +369 -0
- agent/lib/agentfs/paths.ts +62 -0
- agent/sandbox.ts +11 -673
- docker-entrypoint.mjs +7 -1
.env.example
CHANGED
|
@@ -1,19 +1,22 @@
|
|
| 1 |
-
# Workflow world
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
# Session pooler :5432 或 Direct :5432;勿用 Transaction pooler :6543
|
| 3 |
#
|
| 4 |
-
#
|
| 5 |
# 启动时会 Re-enqueue 对方留下的 active run;step 里若带本机绝对路径
|
| 6 |
# (如 H:\...),在另一台机器上会 FatalError(ERR_INVALID_MODULE_SPECIFIER)。
|
| 7 |
-
#
|
| 8 |
-
#
|
| 9 |
-
#
|
| 10 |
-
WORKFLOW_POSTGRES_URL=postgresql://postgres.<project-ref>:<password>@aws-0-<region>.pooler.supabase.com:5432/postgres
|
| 11 |
-
# 本地示例:local_ | HF Secrets 示例:hf_
|
| 12 |
# WORKFLOW_POSTGRES_JOB_PREFIX=local_
|
| 13 |
# Supabase Session 模式常限 pool_size=15;单进程务必留余量(重启/本机/Dashboard 也会占连接)
|
| 14 |
# 须 maxPoolSize >= concurrency。HF 镜像默认 3 / 5。超出会 EMAXCONNSESSION 直接退出。
|
| 15 |
-
WORKFLOW_POSTGRES_WORKER_CONCURRENCY=3
|
| 16 |
-
WORKFLOW_POSTGRES_MAX_POOL_SIZE=5
|
| 17 |
|
| 18 |
# 服务 API 鉴权(/eve/v1 与 /agentfs/api 共用)
|
| 19 |
# 客户端请求头:Authorization: Bearer <AGENT_API_KEY>
|
|
@@ -26,5 +29,15 @@ CUSTOM_BASE_URL=http://127.0.0.1:8090/v1
|
|
| 26 |
CUSTOM_API_KEY=
|
| 27 |
CUSTOM_MODEL=LongCat-2.0
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
# AgentFS / 沙箱数据目录(默认 <appRoot>/data)
|
| 30 |
# EVEAGENT_DATA_DIR=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Workflow world 切换
|
| 2 |
+
# - local:@workflow/world-local → .workflow-data/(本机调试推荐,默认)
|
| 3 |
+
# - postgres:@workflow/world-postgres → Supabase(HF / 生产;镜像默认 postgres)
|
| 4 |
+
WORKFLOW_WORLD=local
|
| 5 |
+
|
| 6 |
+
# 仅 WORKFLOW_WORLD=postgres 时必填
|
| 7 |
# Session pooler :5432 或 Direct :5432;勿用 Transaction pooler :6543
|
| 8 |
#
|
| 9 |
+
# 若本机也用 postgres 且与 HF 共用同一 WORKFLOW_POSTGRES_URL,
|
| 10 |
# 启动时会 Re-enqueue 对方留下的 active run;step 里若带本机绝对路径
|
| 11 |
# (如 H:\...),在另一台机器上会 FatalError(ERR_INVALID_MODULE_SPECIFIER)。
|
| 12 |
+
# 推荐:本机用 local;若必须共库则至少不同 JOB_PREFIX,并在切环境前清 active run。
|
| 13 |
+
# WORKFLOW_POSTGRES_URL=postgresql://postgres.<project-ref>:<password>@aws-0-<region>.pooler.supabase.com:5432/postgres
|
| 14 |
+
# HF Secrets 示例:hf_ | 本机若走 postgres:local_
|
|
|
|
|
|
|
| 15 |
# WORKFLOW_POSTGRES_JOB_PREFIX=local_
|
| 16 |
# Supabase Session 模式常限 pool_size=15;单进程务必留余量(重启/本机/Dashboard 也会占连接)
|
| 17 |
# 须 maxPoolSize >= concurrency。HF 镜像默认 3 / 5。超出会 EMAXCONNSESSION 直接退出。
|
| 18 |
+
# WORKFLOW_POSTGRES_WORKER_CONCURRENCY=3
|
| 19 |
+
# WORKFLOW_POSTGRES_MAX_POOL_SIZE=5
|
| 20 |
|
| 21 |
# 服务 API 鉴权(/eve/v1 与 /agentfs/api 共用)
|
| 22 |
# 客户端请求头:Authorization: Bearer <AGENT_API_KEY>
|
|
|
|
| 29 |
CUSTOM_API_KEY=
|
| 30 |
CUSTOM_MODEL=LongCat-2.0
|
| 31 |
|
| 32 |
+
# Message 功能中心 MCP(agent/connections/message.ts)
|
| 33 |
+
# 注意用 /mcp/ 直连,不要用 /spaces/.../mcp(会 404)
|
| 34 |
+
MESSAGE_MCP_URL=https://hunian003-message.hf.space/mcp/
|
| 35 |
+
# 上游若要求 Bearer,再填;公网无鉴权的 Space 可留空
|
| 36 |
+
# MESSAGE_MCP_TOKEN=
|
| 37 |
+
|
| 38 |
# AgentFS / 沙箱数据目录(默认 <appRoot>/data)
|
| 39 |
# EVEAGENT_DATA_DIR=
|
| 40 |
+
|
| 41 |
+
# AgentFS HTTP 写接口(checkpoint 创建/回退/删除)。默认不注册写路由。
|
| 42 |
+
# 本地需要存档时显式打开:AGENTFS_ENABLE_WRITES=1
|
| 43 |
+
# AGENTFS_ENABLE_WRITES=
|
Dockerfile
CHANGED
|
@@ -19,9 +19,12 @@ RUN pnpm install --frozen-lockfile --ignore-scripts
|
|
| 19 |
|
| 20 |
COPY agent/ ./agent/
|
| 21 |
COPY tsconfig.json ./
|
| 22 |
-
# 构建期通常无 Secrets;占位仅供 agent.ts load,runner 用 Space Secrets
|
|
|
|
|
|
|
| 23 |
ENV CUSTOM_BASE_URL=http://build-time-placeholder.invalid/v1
|
| 24 |
ENV CUSTOM_MODEL=build-placeholder
|
|
|
|
| 25 |
RUN pnpm build
|
| 26 |
|
| 27 |
# ---- runner: 仅生产依赖 + 构建产物(linux 上现装,保证 turso .node)----
|
|
@@ -33,6 +36,8 @@ ENV PORT=3000
|
|
| 33 |
ENV HOST=0.0.0.0
|
| 34 |
ENV NITRO_HOST=0.0.0.0
|
| 35 |
ENV NITRO_PORT=3000
|
|
|
|
|
|
|
| 36 |
# Supabase Session pooler 常见 pool_size=15(EMAXCONNSESSION)。
|
| 37 |
# 单进程 pool 必须远小于 15:重启时旧连接尚未释放,本地若同库也会占名额。
|
| 38 |
# Graphile 要求 maxPoolSize >= concurrency。Secrets 可覆盖。
|
|
|
|
| 19 |
|
| 20 |
COPY agent/ ./agent/
|
| 21 |
COPY tsconfig.json ./
|
| 22 |
+
# 构建期通常无 Secrets;占位仅供 agent.ts / message 连接 load,runner 用 Space Secrets
|
| 23 |
+
# local:构建不必连 Postgres(agent.ts 默认 / 显式 local 不 require URL)
|
| 24 |
+
ENV WORKFLOW_WORLD=local
|
| 25 |
ENV CUSTOM_BASE_URL=http://build-time-placeholder.invalid/v1
|
| 26 |
ENV CUSTOM_MODEL=build-placeholder
|
| 27 |
+
ENV MESSAGE_MCP_URL=https://build-time-placeholder.invalid/mcp/
|
| 28 |
RUN pnpm build
|
| 29 |
|
| 30 |
# ---- runner: 仅生产依赖 + 构建产物(linux 上现装,保证 turso .node)----
|
|
|
|
| 36 |
ENV HOST=0.0.0.0
|
| 37 |
ENV NITRO_HOST=0.0.0.0
|
| 38 |
ENV NITRO_PORT=3000
|
| 39 |
+
# 部署默认 Postgres world;Secrets 可改回 local(无持久卷则重启丢会话)
|
| 40 |
+
ENV WORKFLOW_WORLD=postgres
|
| 41 |
# Supabase Session pooler 常见 pool_size=15(EMAXCONNSESSION)。
|
| 42 |
# 单进程 pool 必须远小于 15:重启时旧连接尚未释放,本地若同库也会占名额。
|
| 43 |
# Graphile 要求 maxPoolSize >= concurrency。Secrets 可覆盖。
|
README.md
CHANGED
|
@@ -23,7 +23,7 @@ short_description: vercel eve 框架驱动的 agent 服务
|
|
| 23 |
| 模型协议 | [AI SDK](https://ai-sdk.dev/) `^7.0.0` + `@ai-sdk/openai-compatible ^3.0.0` |
|
| 24 |
| 沙箱 | just-bash `^3.0.1` 通过 `agentfs-sdk/just-bash` 落到 `agentfs-sdk ^0.6.4` (Turso / SQLite,默认本地文件,环境变量 `EVEAGENT_DATA_DIR` 改数据目录) |
|
| 25 |
| 运行时 | Nitro (Node.js `24.x`) |
|
| 26 |
-
| Workflow 状态 | `
|
| 27 |
| 镜像 | 多阶段 `node:24-bookworm-slim` |
|
| 28 |
|
| 29 |
## API
|
|
@@ -53,15 +53,15 @@ short_description: vercel eve 框架驱动的 agent 服务
|
|
| 53 |
|
| 54 |
| 方法 | 路径 | 用途 |
|
| 55 |
| --- | --- | --- |
|
| 56 |
-
| `GET` | `/agentfs/api/sessions` | 列出 session 沙箱库 |
|
| 57 |
| `GET` | `/agentfs/api/sessions/:id/tree?path=` | 列目录 |
|
| 58 |
| `GET` | `/agentfs/api/sessions/:id/file?path=` | 读文件预览(有大小上限) |
|
| 59 |
| `GET` | `/agentfs/api/sessions/:id/checkpoints` | 列出存档点 |
|
| 60 |
-
| `POST` | `/agentfs/api/sessions/:id/checkpoint` | 创建存档点(body: `{ name }`) |
|
| 61 |
-
| `POST` | `/agentfs/api/sessions/:id/restore` | 回退到存档点(body: `{ checkpoint }`) |
|
| 62 |
-
| `DELETE` | `/agentfs/api/sessions/:id/checkpoint` | 删除存档点(body: `{ name }`) |
|
| 63 |
|
| 64 |
-
实现:`agent/channels/agentfs.ts` + `agent/lib/agentfs-debug.ts`。
|
| 65 |
|
| 66 |
### Workflow 队列回调
|
| 67 |
|
|
@@ -91,10 +91,14 @@ worker 会回打本服务的 `/.well-known/workflow/v1/flow|step`;只代理 `/
|
|
| 91 |
| `CUSTOM_BASE_URL` | 是 | 模型 OpenAI-compatible base URL(无代码默认值) |
|
| 92 |
| `CUSTOM_MODEL` | 是 | 模型 id(无代码默认值) |
|
| 93 |
| `CUSTOM_API_KEY` | 视模型端 | 上游模型 API key |
|
| 94 |
-
| `
|
| 95 |
-
| `
|
| 96 |
-
| `
|
| 97 |
-
| `
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
#### `EMAXCONNSESSION` / `max clients reached in session mode`
|
| 100 |
|
|
@@ -108,13 +112,13 @@ worker 会回打本服务的 `/.well-known/workflow/v1/flow|step`;只代理 `/
|
|
| 108 |
|
| 109 |
1. **先等 1–3 分钟**(或 Supabase Dashboard → Database → 看连接数降下去)再重启 Space。
|
| 110 |
2. HF Secrets / 镜像把池子压小,例如:`WORKER_CONCURRENCY=3`、`MAX_POOL_SIZE=5`(已是 Dockerfile 默认)。
|
| 111 |
-
3. **
|
| 112 |
4. 若 Secrets 里曾设 `MAX_POOL_SIZE=12` 等,删掉或改成 ≤5,否则会覆盖镜像默认。
|
| 113 |
5. 仍���够:升 Supabase 算力 / 提高 pool,或本机与云端分库。
|
| 114 |
|
| 115 |
-
首次部署前在能连库的环境执行 `pnpm workflow:db:setup`。
|
| 116 |
|
| 117 |
-
**
|
| 118 |
|
| 119 |
```text
|
| 120 |
Invalid module "/app/H:%5Cnewworkspace%5C...\\authored-module-map-loader.js"
|
|
@@ -179,19 +183,24 @@ pnpm install
|
|
| 179 |
|
| 180 |
# 2) 配置环境变量(必填项见 .env.example,无代码 fallback)
|
| 181 |
cp .env.example .env
|
| 182 |
-
# 至少:CUSTOM_BASE_URL、CUSTOM_MODEL
|
|
|
|
| 183 |
# 建议:AGENT_API_KEY(公网必填;本机回环可暂空)
|
|
|
|
| 184 |
|
| 185 |
-
# 3) 初始化 workflow 表
|
| 186 |
-
pnpm workflow:db:setup
|
| 187 |
|
| 188 |
# 4) 跑 eve dev(Nitro + TUI,默认 :2000)
|
| 189 |
pnpm dev
|
| 190 |
```
|
| 191 |
|
| 192 |
-
|
|
|
|
|
|
|
|
|
|
| 193 |
|
| 194 |
-
本
|
| 195 |
|
| 196 |
## AgentFS 本地调试页(静态)
|
| 197 |
|
|
@@ -209,7 +218,7 @@ pnpm dev
|
|
| 209 |
|
| 210 |
本 Space 的 `/eve` 与 `/agentfs` 已用 `serviceAuthChain`(Bearer `AGENT_API_KEY` + 本机 `localDev`),**不是** `none()`。未配置 `AGENT_API_KEY` 时公网请求会被拒绝。内置 bash / read_file / write_file 的虚拟文件系统通过 `agent/sandbox.ts` 落到本地 AgentFS(SQLite);just-bash 不跑真二进制,命令局限在 `/workspace` 内的纯 JS 解释。沙箱网络默认关闭;为 websearch 子 agent 仅放行 `api.anysearch.com` / `api.firecrawl.dev`(GET/HEAD/POST)。
|
| 211 |
|
| 212 |
-
**持有有效 API Key 的调用方可以让 LLM 在 AgentFS 沙箱里跑命令、读写沙箱文件,
|
| 213 |
|
| 214 |
### Sandbox 数据布局与持久化
|
| 215 |
|
|
@@ -270,4 +279,4 @@ eve 当前是 beta,使用受 [Vercel Beta 条款](https://vercel.com/docs/releas
|
|
| 270 |
- 配置审批策略、工具限制、连接权限、路由/会话鉴权、沙箱控制、遥测导出等
|
| 271 |
- 在使用非公开、敏感、监管或生产数据前评估默认工具和自定义工具的风险
|
| 272 |
|
| 273 |
-
HF Space 容器**非持久**——重启后 `.eve/sandbox-cache` 与本地 AgentFS 路径会丢。workflow 会话状态
|
|
|
|
| 23 |
| 模型协议 | [AI SDK](https://ai-sdk.dev/) `^7.0.0` + `@ai-sdk/openai-compatible ^3.0.0` |
|
| 24 |
| 沙箱 | just-bash `^3.0.1` 通过 `agentfs-sdk/just-bash` 落到 `agentfs-sdk ^0.6.4` (Turso / SQLite,默认本地文件,环境变量 `EVEAGENT_DATA_DIR` 改数据目录) |
|
| 25 |
| 运行时 | Nitro (Node.js `24.x`) |
|
| 26 |
+
| Workflow 状态 | `WORKFLOW_WORLD=local` → `.workflow-data/`;`postgres` → Supabase(`WORKFLOW_POSTGRES_URL`) |
|
| 27 |
| 镜像 | 多阶段 `node:24-bookworm-slim` |
|
| 28 |
|
| 29 |
## API
|
|
|
|
| 53 |
|
| 54 |
| 方法 | 路径 | 用途 |
|
| 55 |
| --- | --- | --- |
|
| 56 |
+
| `GET` | `/agentfs/api/sessions` | 列出 session 沙箱库(`writesEnabled` 标明写面是否开启;**不**回传宿主机路径) |
|
| 57 |
| `GET` | `/agentfs/api/sessions/:id/tree?path=` | 列目录 |
|
| 58 |
| `GET` | `/agentfs/api/sessions/:id/file?path=` | 读文件预览(有大小上限) |
|
| 59 |
| `GET` | `/agentfs/api/sessions/:id/checkpoints` | 列出存档点 |
|
| 60 |
+
| `POST` | `/agentfs/api/sessions/:id/checkpoint` | 创建存档点(body: `{ name }`)**仅** `AGENTFS_ENABLE_WRITES=1` |
|
| 61 |
+
| `POST` | `/agentfs/api/sessions/:id/restore` | 回退到存档点(body: `{ checkpoint }`)**仅**写面开启 |
|
| 62 |
+
| `DELETE` | `/agentfs/api/sessions/:id/checkpoint` | 删除存档点(body: `{ name }`)**仅**写面开启 |
|
| 63 |
|
| 64 |
+
实现:`agent/channels/agentfs.ts` + `agent/lib/agentfs-debug.ts`(实现拆在 `agent/lib/agentfs-debug/`)。默认**只注册读路由**;checkpoint 写路由须 Secrets/`.env` 设 `AGENTFS_ENABLE_WRITES=1|true|yes` 才会挂载(避免公网默认暴露改写 session FS 的面)。鉴权与 `/eve` 相同(Bearer `AGENT_API_KEY` / 本机 `localDev`)。
|
| 65 |
|
| 66 |
### Workflow 队列回调
|
| 67 |
|
|
|
|
| 91 |
| `CUSTOM_BASE_URL` | 是 | 模型 OpenAI-compatible base URL(无代码默认值) |
|
| 92 |
| `CUSTOM_MODEL` | 是 | 模型 id(无代码默认值) |
|
| 93 |
| `CUSTOM_API_KEY` | 视模型端 | 上游模型 API key |
|
| 94 |
+
| `MESSAGE_MCP_URL` | 是 | Message 功能中心 MCP 端点(如 `https://hunian003-message.hf.space/mcp/`) |
|
| 95 |
+
| `MESSAGE_MCP_TOKEN` | 否 | 上游 MCP Bearer;无鉴权 Space 可省略 |
|
| 96 |
+
| `AGENTFS_ENABLE_WRITES` | 否 | 设为 `1`/`true` 才注册 AgentFS checkpoint 写路由;默认不暴露 |
|
| 97 |
+
| `WORKFLOW_WORLD` | 否 | `local` \| `postgres`。本机默认 `local`;**镜像 runner 默认 `postgres`**。`postgres` 时必须配 `WORKFLOW_POSTGRES_URL` |
|
| 98 |
+
| `WORKFLOW_POSTGRES_URL` | `postgres` 时必填 | Supabase Postgres;Session/Direct `:5432`,**勿用** Transaction `:6543` |
|
| 99 |
+
| `WORKFLOW_POSTGRES_JOB_PREFIX` | 建议(postgres) | 多实例共库时区分 queue job 名,如 `hf_`;**只改 graphile 任务名,表仍共享** |
|
| 100 |
+
| `WORKFLOW_POSTGRES_WORKER_CONCURRENCY` | 否(postgres) | 镜像默认 `3`(包默认 50 会瞬间打满 Session pool) |
|
| 101 |
+
| `WORKFLOW_POSTGRES_MAX_POOL_SIZE` | 否(postgres) | 镜像默认 `5`;须 ≥ concurrency,且 **远小于** Supabase Session `pool_size`(常见 15) |
|
| 102 |
|
| 103 |
#### `EMAXCONNSESSION` / `max clients reached in session mode`
|
| 104 |
|
|
|
|
| 112 |
|
| 113 |
1. **先等 1–3 分钟**(或 Supabase Dashboard → Database → 看连接数降下去)再重启 Space。
|
| 114 |
2. HF Secrets / 镜像把池子压小,例如:`WORKER_CONCURRENCY=3`、`MAX_POOL_SIZE=5`(已是 Dockerfile 默认)。
|
| 115 |
+
3. **本机调试用 `WORKFLOW_WORLD=local`**,不要与 HF 同时大池子连同一 URL。
|
| 116 |
4. 若 Secrets 里曾设 `MAX_POOL_SIZE=12` 等,删掉或改成 ≤5,否则会覆盖镜像默认。
|
| 117 |
5. 仍���够:升 Supabase 算力 / 提高 pool,或本机与云端分库。
|
| 118 |
|
| 119 |
+
`WORKFLOW_WORLD=postgres` 时,首次部署前在能连库的环境执行 `pnpm workflow:db:setup`。
|
| 120 |
|
| 121 |
+
**推荐本机 `local`、HF `postgres`。** 若两边都设 `postgres` 且共用同一 `WORKFLOW_POSTGRES_URL`,启动日志会出现 `Re-enqueued N active run(s)`;本机路径(`H:\...`)在 Linux 容器里会变成类似:
|
| 122 |
|
| 123 |
```text
|
| 124 |
Invalid module "/app/H:%5Cnewworkspace%5C...\\authored-module-map-loader.js"
|
|
|
|
| 183 |
|
| 184 |
# 2) 配置环境变量(必填项见 .env.example,无代码 fallback)
|
| 185 |
cp .env.example .env
|
| 186 |
+
# 至少:CUSTOM_BASE_URL、CUSTOM_MODEL
|
| 187 |
+
# 默认 WORKFLOW_WORLD=local(状态写 .workflow-data/,不必配 Postgres)
|
| 188 |
# 建议:AGENT_API_KEY(公网必填;本机回环可暂空)
|
| 189 |
+
# MESSAGE_MCP_URL 若走 message 连接也需配置
|
| 190 |
|
| 191 |
+
# 3) 仅当 WORKFLOW_WORLD=postgres 时:初始化 workflow 表
|
| 192 |
+
# pnpm workflow:db:setup
|
| 193 |
|
| 194 |
# 4) 跑 eve dev(Nitro + TUI,默认 :2000)
|
| 195 |
pnpm dev
|
| 196 |
```
|
| 197 |
|
| 198 |
+
| `WORKFLOW_WORLD` | 状态落点 | 适用 |
|
| 199 |
+
| --- | --- | --- |
|
| 200 |
+
| `local`(默认) | `.workflow-data/` | 本机调试,与 HF 天然隔离 |
|
| 201 |
+
| `postgres` | Supabase(`WORKFLOW_POSTGRES_URL`) | HF / 生产;镜像 runner 默认 |
|
| 202 |
|
| 203 |
+
本机若改 `WORKFLOW_WORLD=postgres` 且与 HF 共用同一库:**务必**设不同的 `WORKFLOW_POSTGRES_JOB_PREFIX`,且不要留下跨机器的 active run(见上文 SQL)。否则 HF 启动会 re-enqueue 本机 run 并 FatalError。local 与 postgres 的状态**不会**自动迁移。
|
| 204 |
|
| 205 |
## AgentFS 本地调试页(静态)
|
| 206 |
|
|
|
|
| 218 |
|
| 219 |
本 Space 的 `/eve` 与 `/agentfs` 已用 `serviceAuthChain`(Bearer `AGENT_API_KEY` + 本机 `localDev`),**不是** `none()`。未配置 `AGENT_API_KEY` 时公网请求会被拒绝。内置 bash / read_file / write_file 的虚拟文件系统通过 `agent/sandbox.ts` 落到本地 AgentFS(SQLite);just-bash 不跑真二进制,命令局限在 `/workspace` 内的纯 JS 解释。沙箱网络默认关闭;为 websearch 子 agent 仅放行 `api.anysearch.com` / `api.firecrawl.dev`(GET/HEAD/POST)。
|
| 220 |
|
| 221 |
+
**持有有效 API Key 的调用方可以让 LLM 在 AgentFS 沙箱里跑命令、读写沙箱文件;若开启了 `AGENTFS_ENABLE_WRITES`,还可调用 checkpoint 写接口。沙箱外的真实文件系统不会被触及,但 HF 容器非持久,Sandbox 数据库路径务必放到持久卷。**
|
| 222 |
|
| 223 |
### Sandbox 数据布局与持久化
|
| 224 |
|
|
|
|
| 279 |
- 配置审批策略、工具限制、连接权限、路由/会话鉴权、沙箱控制、遥测导出等
|
| 280 |
- 在使用非公开、敏感、监管或生产数据前评估默认工具和自定义工具的风险
|
| 281 |
|
| 282 |
+
HF Space 容器**非持久**——重启后 `.eve/sandbox-cache` 与本地 AgentFS 路径会丢。镜像默认 `WORKFLOW_WORLD=postgres` 时,workflow 会话状态在 Supabase(`WORKFLOW_POSTGRES_URL`),不依赖容器内 `.workflow-data/`;若改回 `local` 则须挂持久卷。免费 `cpu-basic` 档 48 小时无活动会 sleep,下次访问自动唤醒。
|
agent/agent.ts
CHANGED
|
@@ -16,21 +16,48 @@ function requiredEnv(name: string): string {
|
|
| 16 |
return value;
|
| 17 |
}
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
const customProvider = createOpenAICompatible({
|
| 20 |
name: "custom",
|
| 21 |
baseURL: requiredEnv("CUSTOM_BASE_URL"),
|
| 22 |
apiKey: process.env.CUSTOM_API_KEY,
|
| 23 |
});
|
| 24 |
|
|
|
|
|
|
|
| 25 |
export default defineAgent({
|
| 26 |
model: customProvider(requiredEnv("CUSTOM_MODEL")),
|
| 27 |
modelContextWindowTokens: 400000,
|
| 28 |
-
//
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
// 带 native / 动态 require 的包禁止打进 Nitro _libs,否则运行时找不到 .node
|
| 35 |
// (典型:@tursodatabase/database → turso.linux-x64-gnu.node)
|
| 36 |
build: {
|
|
@@ -43,4 +70,3 @@ export default defineAgent({
|
|
| 43 |
],
|
| 44 |
},
|
| 45 |
});
|
| 46 |
-
|
|
|
|
| 16 |
return value;
|
| 17 |
}
|
| 18 |
|
| 19 |
+
/**
|
| 20 |
+
* WORKFLOW_WORLD=local | postgres
|
| 21 |
+
* - local:eve 默认 @workflow/world-local → .workflow-data/(本机调试默认,与云端隔离)
|
| 22 |
+
* - postgres:@workflow/world-postgres,需 WORKFLOW_POSTGRES_URL(HF / 生产)
|
| 23 |
+
*/
|
| 24 |
+
function resolveWorkflowWorld(): string | undefined {
|
| 25 |
+
const raw = process.env.WORKFLOW_WORLD?.trim().toLowerCase();
|
| 26 |
+
const mode = raw || "local";
|
| 27 |
+
|
| 28 |
+
if (mode === "local" || mode === "world-local") {
|
| 29 |
+
return undefined;
|
| 30 |
+
}
|
| 31 |
+
if (mode === "postgres" || mode === "world-postgres") {
|
| 32 |
+
requiredEnv("WORKFLOW_POSTGRES_URL");
|
| 33 |
+
return "@workflow/world-postgres";
|
| 34 |
+
}
|
| 35 |
+
throw new Error(
|
| 36 |
+
`WORKFLOW_WORLD 无效: "${mode}"(允许 local | postgres)`,
|
| 37 |
+
);
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
const customProvider = createOpenAICompatible({
|
| 41 |
name: "custom",
|
| 42 |
baseURL: requiredEnv("CUSTOM_BASE_URL"),
|
| 43 |
apiKey: process.env.CUSTOM_API_KEY,
|
| 44 |
});
|
| 45 |
|
| 46 |
+
const workflowWorld = resolveWorkflowWorld();
|
| 47 |
+
|
| 48 |
export default defineAgent({
|
| 49 |
model: customProvider(requiredEnv("CUSTOM_MODEL")),
|
| 50 |
modelContextWindowTokens: 400000,
|
| 51 |
+
// 按 WORKFLOW_WORLD 选择状态后端;local 时不设 experimental.workflow(eve 默认磁盘 world)
|
| 52 |
+
...(workflowWorld
|
| 53 |
+
? {
|
| 54 |
+
experimental: {
|
| 55 |
+
workflow: {
|
| 56 |
+
world: workflowWorld,
|
| 57 |
+
},
|
| 58 |
+
},
|
| 59 |
+
}
|
| 60 |
+
: {}),
|
| 61 |
// 带 native / 动态 require 的包禁止打进 Nitro _libs,否则运行时找不到 .node
|
| 62 |
// (典型:@tursodatabase/database → turso.linux-x64-gnu.node)
|
| 63 |
build: {
|
|
|
|
| 70 |
],
|
| 71 |
},
|
| 72 |
});
|
|
|
agent/channels/agentfs.ts
CHANGED
|
@@ -1,220 +1,198 @@
|
|
| 1 |
/**
|
| 2 |
* AgentFS API(前端 / 调试静态页对接)。
|
| 3 |
*
|
| 4 |
-
*
|
| 5 |
-
* - GET
|
| 6 |
-
* - GET
|
| 7 |
-
* - GET
|
| 8 |
-
* - GET
|
| 9 |
-
*
|
| 10 |
-
*
|
| 11 |
-
* -
|
|
|
|
|
|
|
| 12 |
*
|
| 13 |
* 鉴权与 /eve 一致:Bearer AGENT_API_KEY,本机回环可走 localDev。
|
|
|
|
| 14 |
* 无内嵌 HTML;本地浏览器见仓库根 `tools/agentfs-browser/index.html`。
|
| 15 |
*/
|
| 16 |
|
| 17 |
-
import { defineChannel, GET, POST
|
| 18 |
import { routeAuth } from "eve/channels/auth";
|
| 19 |
|
| 20 |
import { serviceAuthChain } from "../lib/api-key-auth.js";
|
| 21 |
import {
|
|
|
|
|
|
|
| 22 |
jsonError,
|
|
|
|
| 23 |
listSessionDbs,
|
| 24 |
listTree,
|
| 25 |
readFilePreview,
|
| 26 |
-
resolveDataDir,
|
| 27 |
-
resolveSessionsDir,
|
| 28 |
-
createCheckpoint,
|
| 29 |
-
listCheckpoints,
|
| 30 |
restoreCheckpoint,
|
| 31 |
-
deleteCheckpoint,
|
| 32 |
-
type SessionDbInfo,
|
| 33 |
} from "../lib/agentfs-debug.js";
|
| 34 |
|
| 35 |
const auth = serviceAuthChain();
|
| 36 |
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
return
|
| 41 |
}
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
| 54 |
}
|
| 55 |
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
if (denied) return denied;
|
| 59 |
-
try {
|
| 60 |
-
const sessions = await listSessionDbs();
|
| 61 |
-
return Response.json({
|
| 62 |
-
ok: true,
|
| 63 |
-
dataDir: resolveDataDir(),
|
| 64 |
-
sessionsDir: resolveSessionsDir(),
|
| 65 |
-
sessions: sessions.map((s: SessionDbInfo) => ({
|
| 66 |
-
id: s.id,
|
| 67 |
-
sizeBytes: s.sizeBytes,
|
| 68 |
-
mtimeMs: s.mtimeMs,
|
| 69 |
-
})),
|
| 70 |
-
});
|
| 71 |
-
} catch (err) {
|
| 72 |
-
return jsonError(err);
|
| 73 |
-
}
|
| 74 |
}
|
| 75 |
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
const denied = await requireAuth(req);
|
| 81 |
-
if (denied) return denied;
|
| 82 |
-
const id = await sessionIdFromParams(args);
|
| 83 |
-
if (id instanceof Response) return id;
|
| 84 |
-
try {
|
| 85 |
-
const url = new URL(req.url);
|
| 86 |
-
const path = url.searchParams.get("path");
|
| 87 |
-
const result = await listTree(id, path);
|
| 88 |
-
return Response.json({ ok: true, ...result });
|
| 89 |
-
} catch (err) {
|
| 90 |
-
return jsonError(err);
|
| 91 |
-
}
|
| 92 |
}
|
| 93 |
|
| 94 |
-
|
|
|
|
| 95 |
req: Request,
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
const
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
const path = url.searchParams.get("path");
|
| 105 |
-
if (!path) {
|
| 106 |
-
return Response.json(
|
| 107 |
-
{ ok: false, error: { code: "missing_path", message: "缺少 path 查询参数" } },
|
| 108 |
-
{ status: 400 },
|
| 109 |
-
);
|
| 110 |
-
}
|
| 111 |
-
const preview = await readFilePreview(id, path);
|
| 112 |
-
return Response.json({ ok: true, ...preview });
|
| 113 |
-
} catch (err) {
|
| 114 |
-
return jsonError(err);
|
| 115 |
}
|
|
|
|
| 116 |
}
|
| 117 |
|
| 118 |
-
// ───
|
| 119 |
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
if (id instanceof Response) return id;
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
{ ok: false, error: { code: "missing_name", message: "缺少 name 字段" } },
|
| 133 |
-
{ status: 400 },
|
| 134 |
-
);
|
| 135 |
-
}
|
| 136 |
-
const info = await createCheckpoint(id, body.name);
|
| 137 |
-
return Response.json({ ok: true, checkpoint: info }, { status: 201 });
|
| 138 |
-
} catch (err) {
|
| 139 |
-
return jsonError(err);
|
| 140 |
-
}
|
| 141 |
-
}
|
| 142 |
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
args: { params: Readonly<Record<string, string>> },
|
| 146 |
-
): Promise<Response> {
|
| 147 |
-
const denied = await requireAuth(req);
|
| 148 |
-
if (denied) return denied;
|
| 149 |
-
const id = await sessionIdFromParams(args);
|
| 150 |
if (id instanceof Response) return id;
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
const checkpoints = await listCheckpoints(id);
|
| 153 |
return Response.json({ ok: true, checkpoints });
|
| 154 |
-
}
|
| 155 |
-
|
| 156 |
-
}
|
| 157 |
-
}
|
| 158 |
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
return Response.json({ ok: true, ...result });
|
| 177 |
-
}
|
| 178 |
-
|
| 179 |
-
}
|
| 180 |
-
}
|
| 181 |
|
| 182 |
-
|
| 183 |
-
req: Request,
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
if (
|
| 193 |
-
|
| 194 |
-
{ ok: false, error: { code: "missing_name", message: "缺少 name 字段" } },
|
| 195 |
-
{ status: 400 },
|
| 196 |
-
);
|
| 197 |
-
}
|
| 198 |
-
await deleteCheckpoint(id, body.name);
|
| 199 |
return Response.json({ ok: true });
|
| 200 |
-
}
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
|
| 205 |
export default defineChannel({
|
|
|
|
| 206 |
cors: {
|
| 207 |
origin: "*",
|
| 208 |
methods: ["GET", "POST", "DELETE", "OPTIONS"],
|
| 209 |
allowHeaders: ["Authorization", "Content-Type"],
|
| 210 |
},
|
| 211 |
-
routes: [
|
| 212 |
-
GET("/agentfs/api/sessions", handleSessions),
|
| 213 |
-
GET("/agentfs/api/sessions/:id/tree", handleTree),
|
| 214 |
-
GET("/agentfs/api/sessions/:id/file", handleFile),
|
| 215 |
-
GET("/agentfs/api/sessions/:id/checkpoints", handleListCheckpoints),
|
| 216 |
-
POST("/agentfs/api/sessions/:id/checkpoint", handleCreateCheckpoint),
|
| 217 |
-
POST("/agentfs/api/sessions/:id/restore", handleRestoreCheckpoint),
|
| 218 |
-
DELETE("/agentfs/api/sessions/:id/checkpoint", handleDeleteCheckpoint),
|
| 219 |
-
],
|
| 220 |
});
|
|
|
|
| 1 |
/**
|
| 2 |
* AgentFS API(前端 / 调试静态页对接)。
|
| 3 |
*
|
| 4 |
+
* 读接口(始终注册,需 serviceAuth):
|
| 5 |
+
* - GET /agentfs/api/sessions
|
| 6 |
+
* - GET /agentfs/api/sessions/:id/tree?path=
|
| 7 |
+
* - GET /agentfs/api/sessions/:id/file?path=
|
| 8 |
+
* - GET /agentfs/api/sessions/:id/checkpoints
|
| 9 |
+
*
|
| 10 |
+
* 写接口(仅当 AGENTFS_ENABLE_WRITES=1|true|yes 时注册):
|
| 11 |
+
* - POST /agentfs/api/sessions/:id/checkpoint body: { name }
|
| 12 |
+
* - POST /agentfs/api/sessions/:id/restore body: { checkpoint }
|
| 13 |
+
* - DELETE /agentfs/api/sessions/:id/checkpoint body: { name }
|
| 14 |
*
|
| 15 |
* 鉴权与 /eve 一致:Bearer AGENT_API_KEY,本机回环可走 localDev。
|
| 16 |
+
* 响应不回传宿主机绝对路径,避免信息泄露。
|
| 17 |
* 无内嵌 HTML;本地浏览器见仓库根 `tools/agentfs-browser/index.html`。
|
| 18 |
*/
|
| 19 |
|
| 20 |
+
import { defineChannel, DELETE, GET, POST } from "eve/channels";
|
| 21 |
import { routeAuth } from "eve/channels/auth";
|
| 22 |
|
| 23 |
import { serviceAuthChain } from "../lib/api-key-auth.js";
|
| 24 |
import {
|
| 25 |
+
createCheckpoint,
|
| 26 |
+
deleteCheckpoint,
|
| 27 |
jsonError,
|
| 28 |
+
listCheckpoints,
|
| 29 |
listSessionDbs,
|
| 30 |
listTree,
|
| 31 |
readFilePreview,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
restoreCheckpoint,
|
|
|
|
|
|
|
| 33 |
} from "../lib/agentfs-debug.js";
|
| 34 |
|
| 35 |
const auth = serviceAuthChain();
|
| 36 |
|
| 37 |
+
/** checkpoint 写面:必须显式 opt-in,公网默认不暴露。 */
|
| 38 |
+
function checkpointWritesEnabled(): boolean {
|
| 39 |
+
const v = process.env.AGENTFS_ENABLE_WRITES?.trim().toLowerCase();
|
| 40 |
+
return v === "1" || v === "true" || v === "yes";
|
| 41 |
}
|
| 42 |
|
| 43 |
+
type RouteArgs = { params: Readonly<Record<string, string>> };
|
| 44 |
+
type RouteHandler = (req: Request, args: RouteArgs) => Promise<Response>;
|
| 45 |
+
|
| 46 |
+
/** 鉴权 → 业务;统一把领域错误转 JSON。 */
|
| 47 |
+
function withAuth(handler: RouteHandler): RouteHandler {
|
| 48 |
+
return async (req, args) => {
|
| 49 |
+
const result = await routeAuth(req, auth);
|
| 50 |
+
if (result instanceof Response) return result;
|
| 51 |
+
try {
|
| 52 |
+
return await handler(req, args);
|
| 53 |
+
} catch (err) {
|
| 54 |
+
return jsonError(err);
|
| 55 |
+
}
|
| 56 |
+
};
|
| 57 |
}
|
| 58 |
|
| 59 |
+
function badRequest(code: string, message: string): Response {
|
| 60 |
+
return Response.json({ ok: false, error: { code, message } }, { status: 400 });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
}
|
| 62 |
|
| 63 |
+
function sessionIdFromParams(args: RouteArgs): string | Response {
|
| 64 |
+
const id = args.params.id;
|
| 65 |
+
if (!id) return badRequest("missing_id", "缺少 session id");
|
| 66 |
+
return id;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
}
|
| 68 |
|
| 69 |
+
/** 从 JSON body 取非空字符串字段;非法 body 视为空对象。 */
|
| 70 |
+
async function requiredBodyString(
|
| 71 |
req: Request,
|
| 72 |
+
field: string,
|
| 73 |
+
missingCode: string,
|
| 74 |
+
missingMessage: string,
|
| 75 |
+
): Promise<string | Response> {
|
| 76 |
+
const body = (await req.json().catch(() => ({}))) as Record<string, unknown>;
|
| 77 |
+
const value = body[field];
|
| 78 |
+
if (typeof value !== "string" || value.length === 0) {
|
| 79 |
+
return badRequest(missingCode, missingMessage);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
}
|
| 81 |
+
return value;
|
| 82 |
}
|
| 83 |
|
| 84 |
+
// ─── 读 ──────────────────────────────────────────────────────────────
|
| 85 |
|
| 86 |
+
const handleSessions = withAuth(async (_req, _args) => {
|
| 87 |
+
const sessions = await listSessionDbs();
|
| 88 |
+
return Response.json({
|
| 89 |
+
ok: true,
|
| 90 |
+
writesEnabled: checkpointWritesEnabled(),
|
| 91 |
+
sessions: sessions.map((s) => ({
|
| 92 |
+
id: s.id,
|
| 93 |
+
sizeBytes: s.sizeBytes,
|
| 94 |
+
mtimeMs: s.mtimeMs,
|
| 95 |
+
})),
|
| 96 |
+
});
|
| 97 |
+
});
|
| 98 |
+
|
| 99 |
+
const handleTree = withAuth(async (req: Request, args: RouteArgs) => {
|
| 100 |
+
const id = sessionIdFromParams(args);
|
| 101 |
if (id instanceof Response) return id;
|
| 102 |
+
const path = new URL(req.url).searchParams.get("path");
|
| 103 |
+
const result = await listTree(id, path);
|
| 104 |
+
return Response.json({ ok: true, ...result });
|
| 105 |
+
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
+
const handleFile = withAuth(async (req: Request, args: RouteArgs) => {
|
| 108 |
+
const id = sessionIdFromParams(args);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
if (id instanceof Response) return id;
|
| 110 |
+
const path = new URL(req.url).searchParams.get("path");
|
| 111 |
+
if (!path) return badRequest("missing_path", "缺少 path 查询参数");
|
| 112 |
+
const preview = await readFilePreview(id, path);
|
| 113 |
+
return Response.json({ ok: true, ...preview });
|
| 114 |
+
});
|
| 115 |
+
|
| 116 |
+
const handleListCheckpoints = withAuth(
|
| 117 |
+
async (_req: Request, args: RouteArgs) => {
|
| 118 |
+
const id = sessionIdFromParams(args);
|
| 119 |
+
if (id instanceof Response) return id;
|
| 120 |
const checkpoints = await listCheckpoints(id);
|
| 121 |
return Response.json({ ok: true, checkpoints });
|
| 122 |
+
},
|
| 123 |
+
);
|
|
|
|
|
|
|
| 124 |
|
| 125 |
+
// ─── 写(仅 opt-in 时挂到 routes) ───────────────────────────────────
|
| 126 |
+
|
| 127 |
+
const handleCreateCheckpoint = withAuth(
|
| 128 |
+
async (req: Request, args: RouteArgs) => {
|
| 129 |
+
const id = sessionIdFromParams(args);
|
| 130 |
+
if (id instanceof Response) return id;
|
| 131 |
+
const name = await requiredBodyString(
|
| 132 |
+
req,
|
| 133 |
+
"name",
|
| 134 |
+
"missing_name",
|
| 135 |
+
"缺少 name 字段",
|
| 136 |
+
);
|
| 137 |
+
if (name instanceof Response) return name;
|
| 138 |
+
const info = await createCheckpoint(id, name);
|
| 139 |
+
return Response.json({ ok: true, checkpoint: info }, { status: 201 });
|
| 140 |
+
},
|
| 141 |
+
);
|
| 142 |
+
|
| 143 |
+
const handleRestoreCheckpoint = withAuth(
|
| 144 |
+
async (req: Request, args: RouteArgs) => {
|
| 145 |
+
const id = sessionIdFromParams(args);
|
| 146 |
+
if (id instanceof Response) return id;
|
| 147 |
+
const checkpoint = await requiredBodyString(
|
| 148 |
+
req,
|
| 149 |
+
"checkpoint",
|
| 150 |
+
"missing_checkpoint",
|
| 151 |
+
"缺少 checkpoint 字段",
|
| 152 |
+
);
|
| 153 |
+
if (checkpoint instanceof Response) return checkpoint;
|
| 154 |
+
const result = await restoreCheckpoint(id, checkpoint);
|
| 155 |
return Response.json({ ok: true, ...result });
|
| 156 |
+
},
|
| 157 |
+
);
|
|
|
|
|
|
|
| 158 |
|
| 159 |
+
const handleDeleteCheckpoint = withAuth(
|
| 160 |
+
async (req: Request, args: RouteArgs) => {
|
| 161 |
+
const id = sessionIdFromParams(args);
|
| 162 |
+
if (id instanceof Response) return id;
|
| 163 |
+
const name = await requiredBodyString(
|
| 164 |
+
req,
|
| 165 |
+
"name",
|
| 166 |
+
"missing_name",
|
| 167 |
+
"缺少 name 字段",
|
| 168 |
+
);
|
| 169 |
+
if (name instanceof Response) return name;
|
| 170 |
+
await deleteCheckpoint(id, name);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
return Response.json({ ok: true });
|
| 172 |
+
},
|
| 173 |
+
);
|
| 174 |
+
|
| 175 |
+
const readRoutes = [
|
| 176 |
+
GET("/agentfs/api/sessions", handleSessions),
|
| 177 |
+
GET("/agentfs/api/sessions/:id/tree", handleTree),
|
| 178 |
+
GET("/agentfs/api/sessions/:id/file", handleFile),
|
| 179 |
+
GET("/agentfs/api/sessions/:id/checkpoints", handleListCheckpoints),
|
| 180 |
+
] as const;
|
| 181 |
+
|
| 182 |
+
const writeRoutes = checkpointWritesEnabled()
|
| 183 |
+
? ([
|
| 184 |
+
POST("/agentfs/api/sessions/:id/checkpoint", handleCreateCheckpoint),
|
| 185 |
+
POST("/agentfs/api/sessions/:id/restore", handleRestoreCheckpoint),
|
| 186 |
+
DELETE("/agentfs/api/sessions/:id/checkpoint", handleDeleteCheckpoint),
|
| 187 |
+
] as const)
|
| 188 |
+
: [];
|
| 189 |
|
| 190 |
export default defineChannel({
|
| 191 |
+
// 调试页常跨源(file:// / 其它端口);生产请按域名收紧 origin
|
| 192 |
cors: {
|
| 193 |
origin: "*",
|
| 194 |
methods: ["GET", "POST", "DELETE", "OPTIONS"],
|
| 195 |
allowHeaders: ["Authorization", "Content-Type"],
|
| 196 |
},
|
| 197 |
+
routes: [...readRoutes, ...writeRoutes],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
});
|
agent/connections/message.ts
CHANGED
|
@@ -1,7 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import { defineMcpClientConnection } from "eve/connections";
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
export default defineMcpClientConnection({
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
});
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 扩展工具中心 MCP 连接。
|
| 3 |
+
*
|
| 4 |
+
* URL / 可选 Bearer 一律从环境变量读取,禁止把部署专用端点写死在源码里。
|
| 5 |
+
* - MESSAGE_MCP_URL 必填(Streamable HTTP / SSE),末尾建议带 `/`
|
| 6 |
+
* - MESSAGE_MCP_TOKEN 可选;有值时以 Authorization: Bearer 调用上游
|
| 7 |
+
*
|
| 8 |
+
* 默认 HF 部署端点见 `.env.example` / PROJECT_RULES(不是 `/spaces/.../mcp`)。
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
import { defineMcpClientConnection } from "eve/connections";
|
| 12 |
|
| 13 |
+
/** 规范化 MCP URL:去空白;无尾斜杠时补 `/`(HF Space 路径约定)。 */
|
| 14 |
+
function resolveMessageMcpUrl(): string {
|
| 15 |
+
const raw = process.env.MESSAGE_MCP_URL?.trim();
|
| 16 |
+
if (!raw) {
|
| 17 |
+
throw new Error(
|
| 18 |
+
"MESSAGE_MCP_URL is required(写入 .env 或部署 Secrets;示例见 .env.example)",
|
| 19 |
+
);
|
| 20 |
+
}
|
| 21 |
+
return raw.endsWith("/") ? raw : `${raw}/`;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
const messageMcpToken = process.env.MESSAGE_MCP_TOKEN?.trim() ?? "";
|
| 25 |
+
|
| 26 |
export default defineMcpClientConnection({
|
| 27 |
+
url: resolveMessageMcpUrl(),
|
| 28 |
+
description:
|
| 29 |
+
"扩展工具中心:浏览器自动化(导航、点击、填表、截图)、沙盒文件操作与命令执行、图片 OCR 文字识别、音频转文字、小红书/微博内容提取。",
|
| 30 |
+
...(messageMcpToken.length > 0
|
| 31 |
+
? {
|
| 32 |
+
auth: {
|
| 33 |
+
getToken: async () => ({ token: messageMcpToken }),
|
| 34 |
+
},
|
| 35 |
+
}
|
| 36 |
+
: {}),
|
| 37 |
});
|
agent/lib/agentfs-debug.ts
CHANGED
|
@@ -1,511 +1,58 @@
|
|
| 1 |
/**
|
| 2 |
-
* AgentFS 调试与存档
|
| 3 |
-
*
|
| 4 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
*/
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
};
|
| 39 |
-
|
| 40 |
-
export
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
return resolve(process.cwd(), "data");
|
| 55 |
-
}
|
| 56 |
-
|
| 57 |
-
/** sessions 目录绝对路径。 */
|
| 58 |
-
export function resolveSessionsDir(): string {
|
| 59 |
-
return join(resolveDataDir(), "agentfs", AGENTFS_BACKEND_NAME, "sessions");
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
-
/** 校验 session id,防止路径穿越。 */
|
| 63 |
-
export function assertSessionId(id: string): string {
|
| 64 |
-
if (!SESSION_ID_RE.test(id)) {
|
| 65 |
-
throw new AgentFsDebugError(400, "invalid_session_id", "session id 非法");
|
| 66 |
-
}
|
| 67 |
-
return id;
|
| 68 |
-
}
|
| 69 |
-
|
| 70 |
-
/**
|
| 71 |
-
* 解析虚拟 FS 路径:必须为绝对路径、无 `..`、无空字节。
|
| 72 |
-
* 空或 `/` → `/`。
|
| 73 |
-
*/
|
| 74 |
-
export function normalizeVirtualPath(raw: string | null | undefined): string {
|
| 75 |
-
const input = (raw ?? "/").trim() || "/";
|
| 76 |
-
if (input.includes("\0")) {
|
| 77 |
-
throw new AgentFsDebugError(400, "invalid_path", "路径含非法字符");
|
| 78 |
-
}
|
| 79 |
-
const withSlash = input.startsWith("/") ? input : `/${input}`;
|
| 80 |
-
const parts = withSlash.split("/").filter((p) => p.length > 0);
|
| 81 |
-
const out: string[] = [];
|
| 82 |
-
for (const part of parts) {
|
| 83 |
-
if (part === ".") continue;
|
| 84 |
-
if (part === "..") {
|
| 85 |
-
throw new AgentFsDebugError(400, "invalid_path", "路径不允许包含 ..");
|
| 86 |
-
}
|
| 87 |
-
out.push(part);
|
| 88 |
-
}
|
| 89 |
-
return out.length === 0 ? "/" : `/${out.join("/")}`;
|
| 90 |
-
}
|
| 91 |
-
|
| 92 |
-
/** 将 session id 解析为磁盘上的 .db 绝对路径(id 已禁止路径分隔符)。 */
|
| 93 |
-
export function resolveSessionDbPath(sessionId: string): string {
|
| 94 |
-
const id = assertSessionId(sessionId);
|
| 95 |
-
const sessionsDir = resolveSessionsDir();
|
| 96 |
-
const dbPath = resolve(join(sessionsDir, `${id}.db`));
|
| 97 |
-
if (basename(dbPath) !== `${id}.db`) {
|
| 98 |
-
throw new AgentFsDebugError(400, "invalid_session_id", "session 路径越界");
|
| 99 |
-
}
|
| 100 |
-
return dbPath;
|
| 101 |
-
}
|
| 102 |
-
|
| 103 |
-
/** 列出 sessions 目录下的 *.db(忽略 -wal/-shm)。 */
|
| 104 |
-
export async function listSessionDbs(): Promise<SessionDbInfo[]> {
|
| 105 |
-
const sessionsDir = resolveSessionsDir();
|
| 106 |
-
let names: string[];
|
| 107 |
-
try {
|
| 108 |
-
names = await readdir(sessionsDir);
|
| 109 |
-
} catch (err) {
|
| 110 |
-
const code = (err as NodeJS.ErrnoException).code;
|
| 111 |
-
if (code === "ENOENT") return [];
|
| 112 |
-
throw err;
|
| 113 |
-
}
|
| 114 |
-
|
| 115 |
-
const out: SessionDbInfo[] = [];
|
| 116 |
-
for (const name of names) {
|
| 117 |
-
if (!name.endsWith(".db")) continue;
|
| 118 |
-
if (name.endsWith(".db-wal") || name.endsWith(".db-shm")) continue;
|
| 119 |
-
const id = name.slice(0, -".db".length);
|
| 120 |
-
if (!SESSION_ID_RE.test(id)) continue;
|
| 121 |
-
const dbPath = join(sessionsDir, name);
|
| 122 |
-
try {
|
| 123 |
-
const st = await stat(dbPath);
|
| 124 |
-
if (!st.isFile()) continue;
|
| 125 |
-
out.push({
|
| 126 |
-
id,
|
| 127 |
-
dbPath,
|
| 128 |
-
sizeBytes: st.size,
|
| 129 |
-
mtimeMs: st.mtimeMs,
|
| 130 |
-
});
|
| 131 |
-
} catch {
|
| 132 |
-
// 单文件 stat 失败则跳过
|
| 133 |
-
}
|
| 134 |
-
}
|
| 135 |
-
out.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
| 136 |
-
return out;
|
| 137 |
-
}
|
| 138 |
-
|
| 139 |
-
/** 打开 AgentFS,执行只读回调后关闭。 */
|
| 140 |
-
export async function withAgentFs<T>(
|
| 141 |
-
sessionId: string,
|
| 142 |
-
fn: (agent: Awaited<ReturnType<typeof AgentFS.open>>) => Promise<T>,
|
| 143 |
-
): Promise<T> {
|
| 144 |
-
const dbPath = resolveSessionDbPath(sessionId);
|
| 145 |
-
try {
|
| 146 |
-
await stat(dbPath);
|
| 147 |
-
} catch (err) {
|
| 148 |
-
const code = (err as NodeJS.ErrnoException).code;
|
| 149 |
-
if (code === "ENOENT") {
|
| 150 |
-
throw new AgentFsDebugError(404, "session_not_found", `session 不存在: ${sessionId}`);
|
| 151 |
-
}
|
| 152 |
-
throw err;
|
| 153 |
-
}
|
| 154 |
-
|
| 155 |
-
let agent: Awaited<ReturnType<typeof AgentFS.open>>;
|
| 156 |
-
try {
|
| 157 |
-
agent = await AgentFS.open({ path: dbPath });
|
| 158 |
-
} catch (err) {
|
| 159 |
-
const message = err instanceof Error ? err.message : String(err);
|
| 160 |
-
throw new AgentFsDebugError(
|
| 161 |
-
503,
|
| 162 |
-
"open_failed",
|
| 163 |
-
`无法打开 AgentFS(库可能被占用): ${message}`,
|
| 164 |
-
);
|
| 165 |
-
}
|
| 166 |
-
|
| 167 |
-
try {
|
| 168 |
-
return await fn(agent);
|
| 169 |
-
} finally {
|
| 170 |
-
try {
|
| 171 |
-
await agent.close();
|
| 172 |
-
} catch {
|
| 173 |
-
// 关闭失败不影响主结果
|
| 174 |
-
}
|
| 175 |
-
}
|
| 176 |
-
}
|
| 177 |
-
|
| 178 |
-
/** 列目录(readdirPlus)。 */
|
| 179 |
-
export async function listTree(
|
| 180 |
-
sessionId: string,
|
| 181 |
-
pathRaw: string | null | undefined,
|
| 182 |
-
): Promise<{ path: string; entries: TreeEntry[] }> {
|
| 183 |
-
const path = normalizeVirtualPath(pathRaw);
|
| 184 |
-
return withAgentFs(sessionId, async (agent) => {
|
| 185 |
-
let entries;
|
| 186 |
-
try {
|
| 187 |
-
entries = await agent.fs.readdirPlus(path);
|
| 188 |
-
} catch (err) {
|
| 189 |
-
throw mapFsError(err, path);
|
| 190 |
-
}
|
| 191 |
-
const mapped: TreeEntry[] = entries.map((e) => {
|
| 192 |
-
const childPath = path === "/" ? `/${e.name}` : `${path}/${e.name}`;
|
| 193 |
-
let type: TreeEntry["type"] = "other";
|
| 194 |
-
if (e.stats.isDirectory()) type = "directory";
|
| 195 |
-
else if (e.stats.isFile()) type = "file";
|
| 196 |
-
else if (e.stats.isSymbolicLink()) type = "symlink";
|
| 197 |
-
return {
|
| 198 |
-
name: e.name,
|
| 199 |
-
path: childPath,
|
| 200 |
-
type,
|
| 201 |
-
size: e.stats.size,
|
| 202 |
-
mtime: e.stats.mtime,
|
| 203 |
-
};
|
| 204 |
-
});
|
| 205 |
-
mapped.sort((a, b) => {
|
| 206 |
-
if (a.type === "directory" && b.type !== "directory") return -1;
|
| 207 |
-
if (a.type !== "directory" && b.type === "directory") return 1;
|
| 208 |
-
return a.name.localeCompare(b.name);
|
| 209 |
-
});
|
| 210 |
-
return { path, entries: mapped };
|
| 211 |
-
});
|
| 212 |
-
}
|
| 213 |
-
|
| 214 |
-
/** 读文件预览(限大小)。 */
|
| 215 |
-
export async function readFilePreview(
|
| 216 |
-
sessionId: string,
|
| 217 |
-
pathRaw: string | null | undefined,
|
| 218 |
-
): Promise<FilePreview> {
|
| 219 |
-
const path = normalizeVirtualPath(pathRaw);
|
| 220 |
-
if (path === "/") {
|
| 221 |
-
throw new AgentFsDebugError(400, "is_directory", "不能读取根目录为文件");
|
| 222 |
-
}
|
| 223 |
-
|
| 224 |
-
return withAgentFs(sessionId, async (agent) => {
|
| 225 |
-
let st;
|
| 226 |
-
try {
|
| 227 |
-
st = await agent.fs.stat(path);
|
| 228 |
-
} catch (err) {
|
| 229 |
-
throw mapFsError(err, path);
|
| 230 |
-
}
|
| 231 |
-
if (st.isDirectory()) {
|
| 232 |
-
throw new AgentFsDebugError(400, "is_directory", "路径是目录,请用 tree 接口");
|
| 233 |
-
}
|
| 234 |
-
|
| 235 |
-
let buf: Buffer;
|
| 236 |
-
try {
|
| 237 |
-
buf = await agent.fs.readFile(path);
|
| 238 |
-
} catch (err) {
|
| 239 |
-
throw mapFsError(err, path);
|
| 240 |
-
}
|
| 241 |
-
|
| 242 |
-
const truncated = buf.length > AGENTFS_DEBUG_MAX_FILE_BYTES;
|
| 243 |
-
const slice = truncated ? buf.subarray(0, AGENTFS_DEBUG_MAX_FILE_BYTES) : buf;
|
| 244 |
-
const encoding = looksLikeText(slice) ? "utf-8" : "binary";
|
| 245 |
-
const content =
|
| 246 |
-
encoding === "utf-8"
|
| 247 |
-
? slice.toString("utf-8")
|
| 248 |
-
: slice.toString("base64");
|
| 249 |
-
|
| 250 |
-
return {
|
| 251 |
-
path,
|
| 252 |
-
size: st.size,
|
| 253 |
-
mtime: st.mtime,
|
| 254 |
-
encoding,
|
| 255 |
-
truncated,
|
| 256 |
-
content,
|
| 257 |
-
byteLength: slice.length,
|
| 258 |
-
};
|
| 259 |
-
});
|
| 260 |
-
}
|
| 261 |
-
|
| 262 |
-
function looksLikeText(buf: Buffer): boolean {
|
| 263 |
-
if (buf.length === 0) return true;
|
| 264 |
-
let suspicious = 0;
|
| 265 |
-
const sample = Math.min(buf.length, 4096);
|
| 266 |
-
for (let i = 0; i < sample; i++) {
|
| 267 |
-
const c = buf[i]!;
|
| 268 |
-
if (c === 0) return false;
|
| 269 |
-
// 允许常见控制符:tab/lf/cr
|
| 270 |
-
if (c < 32 && c !== 9 && c !== 10 && c !== 13) suspicious++;
|
| 271 |
-
}
|
| 272 |
-
return suspicious / sample < 0.05;
|
| 273 |
-
}
|
| 274 |
-
|
| 275 |
-
function mapFsError(err: unknown, path: string): AgentFsDebugError {
|
| 276 |
-
const code = (err as NodeJS.ErrnoException)?.code;
|
| 277 |
-
if (code === "ENOENT") {
|
| 278 |
-
return new AgentFsDebugError(404, "not_found", `路径不存在: ${path}`);
|
| 279 |
-
}
|
| 280 |
-
if (code === "ENOTDIR") {
|
| 281 |
-
return new AgentFsDebugError(400, "not_directory", `不是目录: ${path}`);
|
| 282 |
-
}
|
| 283 |
-
if (code === "EISDIR") {
|
| 284 |
-
return new AgentFsDebugError(400, "is_directory", `是目录: ${path}`);
|
| 285 |
-
}
|
| 286 |
-
const message = err instanceof Error ? err.message : String(err);
|
| 287 |
-
return new AgentFsDebugError(500, "fs_error", message);
|
| 288 |
-
}
|
| 289 |
-
|
| 290 |
-
export class AgentFsDebugError extends Error {
|
| 291 |
-
readonly status: number;
|
| 292 |
-
readonly code: string;
|
| 293 |
-
|
| 294 |
-
constructor(status: number, code: string, message: string) {
|
| 295 |
-
super(message);
|
| 296 |
-
this.name = "AgentFsDebugError";
|
| 297 |
-
this.status = status;
|
| 298 |
-
this.code = code;
|
| 299 |
-
}
|
| 300 |
-
}
|
| 301 |
-
|
| 302 |
-
export function jsonError(err: unknown): Response {
|
| 303 |
-
if (err instanceof AgentFsDebugError) {
|
| 304 |
-
return Response.json(
|
| 305 |
-
{ ok: false, error: { code: err.code, message: err.message } },
|
| 306 |
-
{ status: err.status },
|
| 307 |
-
);
|
| 308 |
-
}
|
| 309 |
-
const message = err instanceof Error ? err.message : String(err);
|
| 310 |
-
return Response.json(
|
| 311 |
-
{ ok: false, error: { code: "internal", message } },
|
| 312 |
-
{ status: 500 },
|
| 313 |
-
);
|
| 314 |
-
}
|
| 315 |
-
|
| 316 |
-
// ─── Checkpoint 辅助函数 ────────────────────────────────────────────
|
| 317 |
-
|
| 318 |
-
/** checkpoints 目录:<sessionsDir>/checkpoints/<sessionId>/ */
|
| 319 |
-
export function resolveCheckpointsDir(sessionId: string): string {
|
| 320 |
-
return join(resolveSessionsDir(), "checkpoints", assertSessionId(sessionId));
|
| 321 |
-
}
|
| 322 |
-
|
| 323 |
-
/** 单个存档点 .db 文件路径。 */
|
| 324 |
-
export function resolveCheckpointDbPath(sessionId: string, name: string): string {
|
| 325 |
-
if (!CHECKPOINT_NAME_RE.test(name)) {
|
| 326 |
-
throw new AgentFsDebugError(400, "invalid_checkpoint_name", "存档点名非法(允许中文、字母、数字、._-)");
|
| 327 |
-
}
|
| 328 |
-
return join(resolveCheckpointsDir(sessionId), `${name}.db`);
|
| 329 |
-
}
|
| 330 |
-
|
| 331 |
-
/** 递归复制 src 的 dirPath 下所有文件/目录到 dest。 */
|
| 332 |
-
export async function recursiveCopyFs(
|
| 333 |
-
src: FileSystem,
|
| 334 |
-
dest: FileSystem,
|
| 335 |
-
dirPath: string,
|
| 336 |
-
): Promise<void> {
|
| 337 |
-
const entries = await src.readdirPlus(dirPath);
|
| 338 |
-
for (const entry of entries) {
|
| 339 |
-
const child = dirPath === "/" ? `/${entry.name}` : `${dirPath}/${entry.name}`;
|
| 340 |
-
if (entry.stats.isDirectory()) {
|
| 341 |
-
try {
|
| 342 |
-
await dest.mkdir(child);
|
| 343 |
-
} catch {
|
| 344 |
-
// 已存在则忽略
|
| 345 |
-
}
|
| 346 |
-
await recursiveCopyFs(src, dest, child);
|
| 347 |
-
} else if (entry.stats.isFile()) {
|
| 348 |
-
const content = await src.readFile(child);
|
| 349 |
-
await dest.writeFile(child, content);
|
| 350 |
-
}
|
| 351 |
-
// symlink 和其他类型跳过
|
| 352 |
-
}
|
| 353 |
-
}
|
| 354 |
-
|
| 355 |
-
export type CheckpointInfo = {
|
| 356 |
-
name: string;
|
| 357 |
-
sizeBytes: number;
|
| 358 |
-
mtimeMs: number;
|
| 359 |
-
};
|
| 360 |
-
|
| 361 |
-
/** 确认 session .db 已存在,避免 AgentFS.open 对缺失路径静默建空库。 */
|
| 362 |
-
async function assertSessionDbExists(sessionId: string): Promise<string> {
|
| 363 |
-
const sessionDb = resolveSessionDbPath(sessionId);
|
| 364 |
-
try {
|
| 365 |
-
await stat(sessionDb);
|
| 366 |
-
} catch (err) {
|
| 367 |
-
const code = (err as NodeJS.ErrnoException).code;
|
| 368 |
-
if (code === "ENOENT") {
|
| 369 |
-
throw new AgentFsDebugError(404, "session_not_found", `session 不存在: ${sessionId}`);
|
| 370 |
-
}
|
| 371 |
-
throw err;
|
| 372 |
-
}
|
| 373 |
-
return sessionDb;
|
| 374 |
-
}
|
| 375 |
-
|
| 376 |
-
/** 创建存档点:把 session 当前文件状态快照到一个独立 .db。 */
|
| 377 |
-
export async function createCheckpoint(
|
| 378 |
-
sessionId: string,
|
| 379 |
-
name: string,
|
| 380 |
-
): Promise<CheckpointInfo> {
|
| 381 |
-
// 必须先确认 session 存在:AgentFS.open 会对缺失 path 创建空库
|
| 382 |
-
const sessionDb = await assertSessionDbExists(sessionId);
|
| 383 |
-
const cpDb = resolveCheckpointDbPath(sessionId, name);
|
| 384 |
-
await mkdir(dirname(cpDb), { recursive: true });
|
| 385 |
-
|
| 386 |
-
let sessionAgent: Awaited<ReturnType<typeof AgentFS.open>>;
|
| 387 |
-
try {
|
| 388 |
-
sessionAgent = await AgentFS.open({ path: sessionDb });
|
| 389 |
-
} catch (err) {
|
| 390 |
-
throw mapAgentFsOpenError(err, sessionId);
|
| 391 |
-
}
|
| 392 |
-
let cpAgent: Awaited<ReturnType<typeof AgentFS.open>> | null = null;
|
| 393 |
-
try {
|
| 394 |
-
cpAgent = await AgentFS.open({ path: cpDb });
|
| 395 |
-
await recursiveCopyFs(sessionAgent.fs, cpAgent.fs, "/");
|
| 396 |
-
} finally {
|
| 397 |
-
try { await sessionAgent.close(); } catch { /* ignore */ }
|
| 398 |
-
if (cpAgent) { try { await cpAgent.close(); } catch { /* ignore */ } }
|
| 399 |
-
}
|
| 400 |
-
|
| 401 |
-
const st = await stat(cpDb);
|
| 402 |
-
return { name, sizeBytes: st.size, mtimeMs: st.mtimeMs };
|
| 403 |
-
}
|
| 404 |
-
|
| 405 |
-
/** 列出 session 的所有存档点。 */
|
| 406 |
-
export async function listCheckpoints(sessionId: string): Promise<CheckpointInfo[]> {
|
| 407 |
-
const dir = resolveCheckpointsDir(sessionId);
|
| 408 |
-
let names: string[];
|
| 409 |
-
try {
|
| 410 |
-
names = await readdir(dir);
|
| 411 |
-
} catch (err) {
|
| 412 |
-
const code = (err as NodeJS.ErrnoException).code;
|
| 413 |
-
if (code === "ENOENT") return [];
|
| 414 |
-
throw err;
|
| 415 |
-
}
|
| 416 |
-
|
| 417 |
-
const out: CheckpointInfo[] = [];
|
| 418 |
-
for (const name of names) {
|
| 419 |
-
if (!name.endsWith(".db")) continue;
|
| 420 |
-
const label = name.slice(0, -".db".length);
|
| 421 |
-
if (!CHECKPOINT_NAME_RE.test(label)) continue;
|
| 422 |
-
const fullPath = join(dir, name);
|
| 423 |
-
try {
|
| 424 |
-
const st = await stat(fullPath);
|
| 425 |
-
if (!st.isFile()) continue;
|
| 426 |
-
out.push({ name: label, sizeBytes: st.size, mtimeMs: st.mtimeMs });
|
| 427 |
-
} catch { /* 单文件失败跳过 */ }
|
| 428 |
-
}
|
| 429 |
-
out.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
| 430 |
-
return out;
|
| 431 |
-
}
|
| 432 |
-
|
| 433 |
-
/** 回退 session 到某个存档点:用存档点文件覆盖 session 文件。 */
|
| 434 |
-
export async function restoreCheckpoint(
|
| 435 |
-
sessionId: string,
|
| 436 |
-
name: string,
|
| 437 |
-
): Promise<{ restored: number }> {
|
| 438 |
-
const cpDb = resolveCheckpointDbPath(sessionId, name);
|
| 439 |
-
try {
|
| 440 |
-
await stat(cpDb);
|
| 441 |
-
} catch (err) {
|
| 442 |
-
const code = (err as NodeJS.ErrnoException).code;
|
| 443 |
-
if (code === "ENOENT") {
|
| 444 |
-
throw new AgentFsDebugError(404, "checkpoint_not_found", `存档点不存在: ${name}`);
|
| 445 |
-
}
|
| 446 |
-
throw err;
|
| 447 |
-
}
|
| 448 |
-
|
| 449 |
-
// session 必须已存在;禁止 open 时静默新建空库再写入
|
| 450 |
-
const sessionDb = await assertSessionDbExists(sessionId);
|
| 451 |
-
let cpAgent: Awaited<ReturnType<typeof AgentFS.open>>;
|
| 452 |
-
let sessionAgent: Awaited<ReturnType<typeof AgentFS.open>>;
|
| 453 |
-
try {
|
| 454 |
-
cpAgent = await AgentFS.open({ path: cpDb });
|
| 455 |
-
} catch (err) {
|
| 456 |
-
throw mapAgentFsOpenError(err, `checkpoint:${name}`);
|
| 457 |
-
}
|
| 458 |
-
try {
|
| 459 |
-
sessionAgent = await AgentFS.open({ path: sessionDb });
|
| 460 |
-
} catch (err) {
|
| 461 |
-
try { await cpAgent.close(); } catch { /* ignore */ }
|
| 462 |
-
throw mapAgentFsOpenError(err, sessionId);
|
| 463 |
-
}
|
| 464 |
-
|
| 465 |
-
try {
|
| 466 |
-
// 清空 session 根目录下所有内容,再从存档点完整复制
|
| 467 |
-
const rootEntries = await sessionAgent.fs.readdirPlus("/");
|
| 468 |
-
for (const e of rootEntries) {
|
| 469 |
-
const child = `/${e.name}`;
|
| 470 |
-
try {
|
| 471 |
-
await sessionAgent.fs.rm(child, { recursive: true, force: true });
|
| 472 |
-
} catch {
|
| 473 |
-
// 单个文件/目录删除失败不阻塞整体回退
|
| 474 |
-
}
|
| 475 |
-
}
|
| 476 |
-
await recursiveCopyFs(cpAgent.fs, sessionAgent.fs, "/");
|
| 477 |
-
return { restored: 1 };
|
| 478 |
-
} finally {
|
| 479 |
-
try { await sessionAgent.close(); } catch { /* ignore */ }
|
| 480 |
-
try { await cpAgent.close(); } catch { /* ignore */ }
|
| 481 |
-
}
|
| 482 |
-
}
|
| 483 |
-
|
| 484 |
-
/** 删除单个存档点。 */
|
| 485 |
-
export async function deleteCheckpoint(
|
| 486 |
-
sessionId: string,
|
| 487 |
-
name: string,
|
| 488 |
-
): Promise<void> {
|
| 489 |
-
const cpDb = resolveCheckpointDbPath(sessionId, name);
|
| 490 |
-
// force:false 才能在缺失时抛 ENOENT → 404(force:true 会静默成功)
|
| 491 |
-
try {
|
| 492 |
-
await rm(cpDb, { force: false });
|
| 493 |
-
} catch (err) {
|
| 494 |
-
const code = (err as NodeJS.ErrnoException).code;
|
| 495 |
-
if (code === "ENOENT") {
|
| 496 |
-
throw new AgentFsDebugError(404, "checkpoint_not_found", `存档点不存在: ${name}`);
|
| 497 |
-
}
|
| 498 |
-
throw err;
|
| 499 |
-
}
|
| 500 |
-
}
|
| 501 |
-
|
| 502 |
-
function mapAgentFsOpenError(err: unknown, label: string): AgentFsDebugError {
|
| 503 |
-
const message = err instanceof Error ? err.message : String(err);
|
| 504 |
-
return new AgentFsDebugError(
|
| 505 |
-
503,
|
| 506 |
-
"open_failed",
|
| 507 |
-
`无法打开 AgentFS(${label}): ${message}`,
|
| 508 |
-
);
|
| 509 |
-
}
|
| 510 |
-
|
| 511 |
-
|
|
|
|
| 1 |
/**
|
| 2 |
+
* AgentFS 调试与存档公开 API。
|
| 3 |
+
*
|
| 4 |
+
* 实现拆在 `lib/agentfs-debug/`:
|
| 5 |
+
* - browse:只读树 / 文件预览
|
| 6 |
+
* - checkpoint:存档创建 / 列表 / 回退 / 删除
|
| 7 |
+
* - sessions:磁盘 session 库列表
|
| 8 |
+
*
|
| 9 |
+
* 供 `channels/agentfs.ts` 与外部前端对接。写操作仅限 checkpoint API。
|
| 10 |
*/
|
| 11 |
|
| 12 |
+
export {
|
| 13 |
+
AGENTFS_DEBUG_MAX_FILE_BYTES,
|
| 14 |
+
CHECKPOINT_NAME_RE,
|
| 15 |
+
} from "./agentfs-debug/constants.js";
|
| 16 |
+
|
| 17 |
+
export {
|
| 18 |
+
AgentFsDebugError,
|
| 19 |
+
jsonError,
|
| 20 |
+
mapAgentFsOpenError,
|
| 21 |
+
mapFsError,
|
| 22 |
+
} from "./agentfs-debug/errors.js";
|
| 23 |
+
|
| 24 |
+
export {
|
| 25 |
+
AGENTFS_BACKEND_NAME,
|
| 26 |
+
assertSessionId,
|
| 27 |
+
normalizeVirtualPath,
|
| 28 |
+
resolveCheckpointDbPath,
|
| 29 |
+
resolveCheckpointsDir,
|
| 30 |
+
resolveDataDir,
|
| 31 |
+
resolveSessionDbPath,
|
| 32 |
+
resolveSessionsDir,
|
| 33 |
+
} from "./agentfs-debug/paths.js";
|
| 34 |
+
|
| 35 |
+
export {
|
| 36 |
+
assertSessionDbExists,
|
| 37 |
+
withAgentFs,
|
| 38 |
+
withAgentFsAtPath,
|
| 39 |
+
} from "./agentfs-debug/open.js";
|
| 40 |
+
|
| 41 |
+
export { listSessionDbs } from "./agentfs-debug/sessions.js";
|
| 42 |
+
|
| 43 |
+
export { listTree, readFilePreview } from "./agentfs-debug/browse.js";
|
| 44 |
+
|
| 45 |
+
export {
|
| 46 |
+
createCheckpoint,
|
| 47 |
+
deleteCheckpoint,
|
| 48 |
+
listCheckpoints,
|
| 49 |
+
recursiveCopyFs,
|
| 50 |
+
restoreCheckpoint,
|
| 51 |
+
} from "./agentfs-debug/checkpoint.js";
|
| 52 |
+
|
| 53 |
+
export type {
|
| 54 |
+
CheckpointInfo,
|
| 55 |
+
FilePreview,
|
| 56 |
+
SessionDbInfo,
|
| 57 |
+
TreeEntry,
|
| 58 |
+
} from "./agentfs-debug/types.js";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
agent/lib/agentfs-debug/browse.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 只读浏览:列目录、读文件预览。
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import { AGENTFS_DEBUG_MAX_FILE_BYTES } from "./constants.js";
|
| 6 |
+
import { AgentFsDebugError, mapFsError } from "./errors.js";
|
| 7 |
+
import { withAgentFs } from "./open.js";
|
| 8 |
+
import { joinVirtualPath, normalizeVirtualPath } from "./paths.js";
|
| 9 |
+
import type { FilePreview, TreeEntry } from "./types.js";
|
| 10 |
+
|
| 11 |
+
/** 列目录(readdirPlus)。 */
|
| 12 |
+
export async function listTree(
|
| 13 |
+
sessionId: string,
|
| 14 |
+
pathRaw: string | null | undefined,
|
| 15 |
+
): Promise<{ path: string; entries: TreeEntry[] }> {
|
| 16 |
+
const path = normalizeVirtualPath(pathRaw);
|
| 17 |
+
return withAgentFs(sessionId, async (agent) => {
|
| 18 |
+
let entries;
|
| 19 |
+
try {
|
| 20 |
+
entries = await agent.fs.readdirPlus(path);
|
| 21 |
+
} catch (err) {
|
| 22 |
+
throw mapFsError(err, path);
|
| 23 |
+
}
|
| 24 |
+
const mapped: TreeEntry[] = entries.map((e) => {
|
| 25 |
+
let type: TreeEntry["type"] = "other";
|
| 26 |
+
if (e.stats.isDirectory()) type = "directory";
|
| 27 |
+
else if (e.stats.isFile()) type = "file";
|
| 28 |
+
else if (e.stats.isSymbolicLink()) type = "symlink";
|
| 29 |
+
return {
|
| 30 |
+
name: e.name,
|
| 31 |
+
path: joinVirtualPath(path, e.name),
|
| 32 |
+
type,
|
| 33 |
+
size: e.stats.size,
|
| 34 |
+
mtime: e.stats.mtime,
|
| 35 |
+
};
|
| 36 |
+
});
|
| 37 |
+
mapped.sort((a, b) => {
|
| 38 |
+
if (a.type === "directory" && b.type !== "directory") return -1;
|
| 39 |
+
if (a.type !== "directory" && b.type === "directory") return 1;
|
| 40 |
+
return a.name.localeCompare(b.name);
|
| 41 |
+
});
|
| 42 |
+
return { path, entries: mapped };
|
| 43 |
+
});
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
/** 读文件预览(限大小;文本 utf-8,否则 base64)。 */
|
| 47 |
+
export async function readFilePreview(
|
| 48 |
+
sessionId: string,
|
| 49 |
+
pathRaw: string | null | undefined,
|
| 50 |
+
): Promise<FilePreview> {
|
| 51 |
+
const path = normalizeVirtualPath(pathRaw);
|
| 52 |
+
if (path === "/") {
|
| 53 |
+
throw new AgentFsDebugError(400, "is_directory", "不能读取根目录为文件");
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
return withAgentFs(sessionId, async (agent) => {
|
| 57 |
+
let st;
|
| 58 |
+
try {
|
| 59 |
+
st = await agent.fs.stat(path);
|
| 60 |
+
} catch (err) {
|
| 61 |
+
throw mapFsError(err, path);
|
| 62 |
+
}
|
| 63 |
+
if (st.isDirectory()) {
|
| 64 |
+
throw new AgentFsDebugError(
|
| 65 |
+
400,
|
| 66 |
+
"is_directory",
|
| 67 |
+
"路径是目录,请用 tree 接口",
|
| 68 |
+
);
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
let buf: Buffer;
|
| 72 |
+
try {
|
| 73 |
+
buf = await agent.fs.readFile(path);
|
| 74 |
+
} catch (err) {
|
| 75 |
+
throw mapFsError(err, path);
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
const truncated = buf.length > AGENTFS_DEBUG_MAX_FILE_BYTES;
|
| 79 |
+
const slice = truncated
|
| 80 |
+
? buf.subarray(0, AGENTFS_DEBUG_MAX_FILE_BYTES)
|
| 81 |
+
: buf;
|
| 82 |
+
const encoding = looksLikeText(slice) ? "utf-8" : "binary";
|
| 83 |
+
const content =
|
| 84 |
+
encoding === "utf-8" ? slice.toString("utf-8") : slice.toString("base64");
|
| 85 |
+
|
| 86 |
+
return {
|
| 87 |
+
path,
|
| 88 |
+
size: st.size,
|
| 89 |
+
mtime: st.mtime,
|
| 90 |
+
encoding,
|
| 91 |
+
truncated,
|
| 92 |
+
content,
|
| 93 |
+
byteLength: slice.length,
|
| 94 |
+
};
|
| 95 |
+
});
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
/** 启发式判断 buffer 是否更像文本(含空字节则否)。 */
|
| 99 |
+
function looksLikeText(buf: Buffer): boolean {
|
| 100 |
+
if (buf.length === 0) return true;
|
| 101 |
+
let suspicious = 0;
|
| 102 |
+
const sample = Math.min(buf.length, 4096);
|
| 103 |
+
for (let i = 0; i < sample; i++) {
|
| 104 |
+
const c = buf[i]!;
|
| 105 |
+
if (c === 0) return false;
|
| 106 |
+
// 允许常见控制符:tab / lf / cr
|
| 107 |
+
if (c < 32 && c !== 9 && c !== 10 && c !== 13) suspicious++;
|
| 108 |
+
}
|
| 109 |
+
return suspicious / sample < 0.05;
|
| 110 |
+
}
|
agent/lib/agentfs-debug/checkpoint.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Session 文件系统存档点:创建 / 列表 / 回退 / 删除。
|
| 3 |
+
*
|
| 4 |
+
* 存档库落在 `<sessionsDir>/checkpoints/<sessionId>/<name>.db`,
|
| 5 |
+
* 与运行中 session 库分离。
|
| 6 |
+
*
|
| 7 |
+
* create / restore 会读写「可能正被 sandbox host 占用」的 session 库:
|
| 8 |
+
* 必须经 HostRegistry.withExclusiveAccess(evict → 独占 open → 不自动重开),
|
| 9 |
+
* 禁止绕过 host 二次 open 造成锁竞争 / 半写 / 回滚后视图分裂。
|
| 10 |
+
*/
|
| 11 |
+
|
| 12 |
+
import { readdir, rm, stat } from "node:fs/promises";
|
| 13 |
+
import { join } from "node:path";
|
| 14 |
+
|
| 15 |
+
import type { FileSystem } from "agentfs-sdk";
|
| 16 |
+
|
| 17 |
+
import { hostRegistry } from "../agentfs-sandbox/host.js";
|
| 18 |
+
import { CHECKPOINT_NAME_RE } from "./constants.js";
|
| 19 |
+
import { AgentFsDebugError } from "./errors.js";
|
| 20 |
+
import { assertSessionDbExists, withAgentFsAtPath } from "./open.js";
|
| 21 |
+
import {
|
| 22 |
+
joinVirtualPath,
|
| 23 |
+
resolveCheckpointDbPath,
|
| 24 |
+
resolveCheckpointsDir,
|
| 25 |
+
} from "./paths.js";
|
| 26 |
+
import type { CheckpointInfo } from "./types.js";
|
| 27 |
+
|
| 28 |
+
/** 递归复制 src 的 dirPath 下所有文件/目录到 dest(跳过 symlink 等)。 */
|
| 29 |
+
export async function recursiveCopyFs(
|
| 30 |
+
src: FileSystem,
|
| 31 |
+
dest: FileSystem,
|
| 32 |
+
dirPath: string,
|
| 33 |
+
): Promise<void> {
|
| 34 |
+
const entries = await src.readdirPlus(dirPath);
|
| 35 |
+
for (const entry of entries) {
|
| 36 |
+
const child = joinVirtualPath(dirPath, entry.name);
|
| 37 |
+
if (entry.stats.isDirectory()) {
|
| 38 |
+
try {
|
| 39 |
+
await dest.mkdir(child);
|
| 40 |
+
} catch {
|
| 41 |
+
// 已存在则忽略
|
| 42 |
+
}
|
| 43 |
+
await recursiveCopyFs(src, dest, child);
|
| 44 |
+
} else if (entry.stats.isFile()) {
|
| 45 |
+
const content = await src.readFile(child);
|
| 46 |
+
await dest.writeFile(child, content);
|
| 47 |
+
}
|
| 48 |
+
// symlink 和其他类型跳过
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
/** 清空虚拟 FS 根下所有条目(单条失败不阻塞)。 */
|
| 53 |
+
async function clearFsRoot(fs: FileSystem): Promise<void> {
|
| 54 |
+
const rootEntries = await fs.readdirPlus("/");
|
| 55 |
+
for (const e of rootEntries) {
|
| 56 |
+
try {
|
| 57 |
+
await fs.rm(joinVirtualPath("/", e.name), {
|
| 58 |
+
recursive: true,
|
| 59 |
+
force: true,
|
| 60 |
+
});
|
| 61 |
+
} catch {
|
| 62 |
+
// 单个文件/目录删除失败不阻塞整体
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
/** 删除 SQLite 主库及常见旁路文件(-wal / -shm)。 */
|
| 68 |
+
async function removeSqliteFiles(dbPath: string): Promise<void> {
|
| 69 |
+
for (const p of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
|
| 70 |
+
try {
|
| 71 |
+
await rm(p, { force: true });
|
| 72 |
+
} catch {
|
| 73 |
+
// 不存在或瞬时占用:留给后续 open 报错
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
/**
|
| 79 |
+
* 在 session 库上独占执行:先踢 live host,再 withAgentFsAtPath。
|
| 80 |
+
* checkpoint 库本身无 host 缓存,仍可在 fn 内独立 open。
|
| 81 |
+
*/
|
| 82 |
+
async function withExclusiveSessionDb<T>(
|
| 83 |
+
sessionDb: string,
|
| 84 |
+
fn: () => Promise<T>,
|
| 85 |
+
): Promise<T> {
|
| 86 |
+
return hostRegistry.withExclusiveAccess(sessionDb, fn);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
/** 创建存档点:把 session 当前文件状态完整快照到独立 .db(同名则整体替换)。 */
|
| 90 |
+
export async function createCheckpoint(
|
| 91 |
+
sessionId: string,
|
| 92 |
+
name: string,
|
| 93 |
+
): Promise<CheckpointInfo> {
|
| 94 |
+
// 必须先确认 session 存在:AgentFS.open 会对缺失 path 创建空库
|
| 95 |
+
const sessionDb = await assertSessionDbExists(sessionId);
|
| 96 |
+
const cpDb = resolveCheckpointDbPath(sessionId, name);
|
| 97 |
+
|
| 98 |
+
// 同名全量替换:先清磁盘文件,再 open 新建,避免旧树残留
|
| 99 |
+
await removeSqliteFiles(cpDb);
|
| 100 |
+
|
| 101 |
+
// exclusive:禁止在 live host 仍占 session 库时二次 open 读快照
|
| 102 |
+
await withExclusiveSessionDb(sessionDb, async () => {
|
| 103 |
+
await withAgentFsAtPath(
|
| 104 |
+
sessionDb,
|
| 105 |
+
async (sessionAgent) => {
|
| 106 |
+
await withAgentFsAtPath(
|
| 107 |
+
cpDb,
|
| 108 |
+
async (cpAgent) => {
|
| 109 |
+
await recursiveCopyFs(sessionAgent.fs, cpAgent.fs, "/");
|
| 110 |
+
},
|
| 111 |
+
{ mustExist: false, label: `checkpoint:${name}` },
|
| 112 |
+
);
|
| 113 |
+
},
|
| 114 |
+
{ mustExist: true, label: sessionId },
|
| 115 |
+
);
|
| 116 |
+
});
|
| 117 |
+
|
| 118 |
+
const st = await stat(cpDb);
|
| 119 |
+
return { name, sizeBytes: st.size, mtimeMs: st.mtimeMs };
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
/** 列出 session 的所有存档点。 */
|
| 123 |
+
export async function listCheckpoints(
|
| 124 |
+
sessionId: string,
|
| 125 |
+
): Promise<CheckpointInfo[]> {
|
| 126 |
+
// 校验 id;目录不存在视为无存档
|
| 127 |
+
const dir = resolveCheckpointsDir(sessionId);
|
| 128 |
+
let names: string[];
|
| 129 |
+
try {
|
| 130 |
+
names = await readdir(dir);
|
| 131 |
+
} catch (err) {
|
| 132 |
+
const code = (err as NodeJS.ErrnoException).code;
|
| 133 |
+
if (code === "ENOENT") return [];
|
| 134 |
+
throw err;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
const out: CheckpointInfo[] = [];
|
| 138 |
+
for (const name of names) {
|
| 139 |
+
if (!name.endsWith(".db")) continue;
|
| 140 |
+
const label = name.slice(0, -".db".length);
|
| 141 |
+
if (!CHECKPOINT_NAME_RE.test(label)) continue;
|
| 142 |
+
const fullPath = join(dir, name);
|
| 143 |
+
try {
|
| 144 |
+
const st = await stat(fullPath);
|
| 145 |
+
if (!st.isFile()) continue;
|
| 146 |
+
out.push({ name: label, sizeBytes: st.size, mtimeMs: st.mtimeMs });
|
| 147 |
+
} catch {
|
| 148 |
+
// 单文件失败跳过
|
| 149 |
+
}
|
| 150 |
+
}
|
| 151 |
+
out.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
| 152 |
+
return out;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
/** 回退 session 到某个存档点:清空 session 根后从存档完整复制。 */
|
| 156 |
+
export async function restoreCheckpoint(
|
| 157 |
+
sessionId: string,
|
| 158 |
+
name: string,
|
| 159 |
+
): Promise<{ restored: number }> {
|
| 160 |
+
const cpDb = resolveCheckpointDbPath(sessionId, name);
|
| 161 |
+
// session 必须已存在;禁止 open 时静默新建空库再写入
|
| 162 |
+
const sessionDb = await assertSessionDbExists(sessionId);
|
| 163 |
+
|
| 164 |
+
// exclusive:evict host → 独占写回滚 → host 失效,避免工具继续看旧盘
|
| 165 |
+
await withExclusiveSessionDb(sessionDb, async () => {
|
| 166 |
+
await withAgentFsAtPath(
|
| 167 |
+
cpDb,
|
| 168 |
+
async (cpAgent) => {
|
| 169 |
+
await withAgentFsAtPath(
|
| 170 |
+
sessionDb,
|
| 171 |
+
async (sessionAgent) => {
|
| 172 |
+
await clearFsRoot(sessionAgent.fs);
|
| 173 |
+
await recursiveCopyFs(cpAgent.fs, sessionAgent.fs, "/");
|
| 174 |
+
},
|
| 175 |
+
{ mustExist: true, label: sessionId },
|
| 176 |
+
);
|
| 177 |
+
},
|
| 178 |
+
{
|
| 179 |
+
mustExist: true,
|
| 180 |
+
label: `checkpoint:${name}`,
|
| 181 |
+
notFound: {
|
| 182 |
+
code: "checkpoint_not_found",
|
| 183 |
+
message: `存档点不存在: ${name}`,
|
| 184 |
+
},
|
| 185 |
+
},
|
| 186 |
+
);
|
| 187 |
+
});
|
| 188 |
+
|
| 189 |
+
return { restored: 1 };
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
/** 删除单个存档点(含 -wal/-shm)。 */
|
| 193 |
+
export async function deleteCheckpoint(
|
| 194 |
+
sessionId: string,
|
| 195 |
+
name: string,
|
| 196 |
+
): Promise<void> {
|
| 197 |
+
const cpDb = resolveCheckpointDbPath(sessionId, name);
|
| 198 |
+
// force:false 才能在缺失时抛 ENOENT → 404
|
| 199 |
+
try {
|
| 200 |
+
await rm(cpDb, { force: false });
|
| 201 |
+
} catch (err) {
|
| 202 |
+
const code = (err as NodeJS.ErrnoException).code;
|
| 203 |
+
if (code === "ENOENT") {
|
| 204 |
+
throw new AgentFsDebugError(
|
| 205 |
+
404,
|
| 206 |
+
"checkpoint_not_found",
|
| 207 |
+
`存档点不存在: ${name}`,
|
| 208 |
+
);
|
| 209 |
+
}
|
| 210 |
+
throw err;
|
| 211 |
+
}
|
| 212 |
+
// 旁路文件尽量清理;主库已删成功即视为删除完成
|
| 213 |
+
for (const p of [`${cpDb}-wal`, `${cpDb}-shm`]) {
|
| 214 |
+
try {
|
| 215 |
+
await rm(p, { force: true });
|
| 216 |
+
} catch {
|
| 217 |
+
// ignore
|
| 218 |
+
}
|
| 219 |
+
}
|
| 220 |
+
}
|
agent/lib/agentfs-debug/constants.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* AgentFS 调试与 checkpoint 相关常量。
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
/** 单文件预览上限(字节),超出只返回 meta + 截断提示。 */
|
| 6 |
+
export const AGENTFS_DEBUG_MAX_FILE_BYTES = 256 * 1024;
|
| 7 |
+
|
| 8 |
+
/** 存档点名允许字符(中文 + 字母数字 + 部分符号)。 */
|
| 9 |
+
export const CHECKPOINT_NAME_RE = /^[a-zA-Z0-9._\-\u4e00-\u9fff]{1,100}$/;
|
| 10 |
+
|
| 11 |
+
/**
|
| 12 |
+
* sessionKey 文件名允许的字符。
|
| 13 |
+
* 与 eve `sanitizeRuntimeSandboxKey` 对齐并略放宽长度上限。
|
| 14 |
+
*/
|
| 15 |
+
export const SESSION_ID_RE = /^[a-zA-Z0-9._-]{1,200}$/;
|
agent/lib/agentfs-debug/errors.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* AgentFS 调试 API 错误类型与 HTTP 映射。
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
export class AgentFsDebugError extends Error {
|
| 6 |
+
readonly status: number;
|
| 7 |
+
readonly code: string;
|
| 8 |
+
|
| 9 |
+
constructor(status: number, code: string, message: string) {
|
| 10 |
+
super(message);
|
| 11 |
+
this.name = "AgentFsDebugError";
|
| 12 |
+
this.status = status;
|
| 13 |
+
this.code = code;
|
| 14 |
+
}
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
/** 把未知错误转成 JSON Response(channel 层统一 catch)。 */
|
| 18 |
+
export function jsonError(err: unknown): Response {
|
| 19 |
+
if (err instanceof AgentFsDebugError) {
|
| 20 |
+
return Response.json(
|
| 21 |
+
{ ok: false, error: { code: err.code, message: err.message } },
|
| 22 |
+
{ status: err.status },
|
| 23 |
+
);
|
| 24 |
+
}
|
| 25 |
+
const message = err instanceof Error ? err.message : String(err);
|
| 26 |
+
return Response.json(
|
| 27 |
+
{ ok: false, error: { code: "internal", message } },
|
| 28 |
+
{ status: 500 },
|
| 29 |
+
);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
/** 虚拟 FS 操作错误 → 调试 API 错误。 */
|
| 33 |
+
export function mapFsError(err: unknown, path: string): AgentFsDebugError {
|
| 34 |
+
const code = (err as NodeJS.ErrnoException)?.code;
|
| 35 |
+
if (code === "ENOENT") {
|
| 36 |
+
return new AgentFsDebugError(404, "not_found", `路径不存在: ${path}`);
|
| 37 |
+
}
|
| 38 |
+
if (code === "ENOTDIR") {
|
| 39 |
+
return new AgentFsDebugError(400, "not_directory", `不是目录: ${path}`);
|
| 40 |
+
}
|
| 41 |
+
if (code === "EISDIR") {
|
| 42 |
+
return new AgentFsDebugError(400, "is_directory", `是目录: ${path}`);
|
| 43 |
+
}
|
| 44 |
+
const message = err instanceof Error ? err.message : String(err);
|
| 45 |
+
return new AgentFsDebugError(500, "fs_error", message);
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
/** AgentFS.open 失败(常见:库被占用、损坏)。 */
|
| 49 |
+
export function mapAgentFsOpenError(
|
| 50 |
+
err: unknown,
|
| 51 |
+
label: string,
|
| 52 |
+
): AgentFsDebugError {
|
| 53 |
+
const message = err instanceof Error ? err.message : String(err);
|
| 54 |
+
return new AgentFsDebugError(
|
| 55 |
+
503,
|
| 56 |
+
"open_failed",
|
| 57 |
+
`无法打开 AgentFS(${label}): ${message}`,
|
| 58 |
+
);
|
| 59 |
+
}
|
agent/lib/agentfs-debug/open.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 短生命周期打开 AgentFS:回调结束即 close,不进入 sandbox host 缓存。
|
| 3 |
+
*
|
| 4 |
+
* 适用:
|
| 5 |
+
* - 只读 browse(tree / file preview)
|
| 6 |
+
* - checkpoint 库(无 live host)
|
| 7 |
+
* - 已在 HostRegistry.withExclusiveAccess 保护下的 session 库读写
|
| 8 |
+
*
|
| 9 |
+
* 禁止:在 live host 可能仍占用 session .db 时,绕过 exclusive 直接
|
| 10 |
+
* withAgentFsAtPath(sessionDb) 做 create/restore 等大范围改写。
|
| 11 |
+
*/
|
| 12 |
+
|
| 13 |
+
import { mkdir, stat } from "node:fs/promises";
|
| 14 |
+
import { dirname } from "node:path";
|
| 15 |
+
|
| 16 |
+
import { AgentFS } from "agentfs-sdk";
|
| 17 |
+
|
| 18 |
+
import { AgentFsDebugError, mapAgentFsOpenError } from "./errors.js";
|
| 19 |
+
import { resolveSessionDbPath } from "./paths.js";
|
| 20 |
+
|
| 21 |
+
export type OpenAgent = Awaited<ReturnType<typeof AgentFS.open>>;
|
| 22 |
+
|
| 23 |
+
async function closeQuietly(agent: OpenAgent): Promise<void> {
|
| 24 |
+
try {
|
| 25 |
+
await agent.close();
|
| 26 |
+
} catch {
|
| 27 |
+
// 关闭失败不影响主结果
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
/** 确认磁盘上的 .db 已存在,避免 AgentFS.open 对缺失路径静默建空库。 */
|
| 32 |
+
export async function assertDbExists(
|
| 33 |
+
dbPath: string,
|
| 34 |
+
notFound: { code: string; message: string },
|
| 35 |
+
): Promise<void> {
|
| 36 |
+
try {
|
| 37 |
+
await stat(dbPath);
|
| 38 |
+
} catch (err) {
|
| 39 |
+
const code = (err as NodeJS.ErrnoException).code;
|
| 40 |
+
if (code === "ENOENT") {
|
| 41 |
+
throw new AgentFsDebugError(404, notFound.code, notFound.message);
|
| 42 |
+
}
|
| 43 |
+
throw err;
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
/** 确认 session .db 已存在。 */
|
| 48 |
+
export async function assertSessionDbExists(sessionId: string): Promise<string> {
|
| 49 |
+
const sessionDb = resolveSessionDbPath(sessionId);
|
| 50 |
+
await assertDbExists(sessionDb, {
|
| 51 |
+
code: "session_not_found",
|
| 52 |
+
message: `session 不存在: ${sessionId}`,
|
| 53 |
+
});
|
| 54 |
+
return sessionDb;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/**
|
| 58 |
+
* 在指定 db 路径上打开 AgentFS,执行回调后关闭。
|
| 59 |
+
* @param mustExist 为 true 时禁止 open 静默创建空库;为 false 时会 mkdir 父目录后 open(checkpoint 新建)。
|
| 60 |
+
* @param notFound mustExist 时 404 的 code/message;默认 session_not_found。
|
| 61 |
+
*/
|
| 62 |
+
export async function withAgentFsAtPath<T>(
|
| 63 |
+
dbPath: string,
|
| 64 |
+
fn: (agent: OpenAgent) => Promise<T>,
|
| 65 |
+
opts?: {
|
| 66 |
+
mustExist?: boolean;
|
| 67 |
+
label?: string;
|
| 68 |
+
notFound?: { code: string; message: string };
|
| 69 |
+
},
|
| 70 |
+
): Promise<T> {
|
| 71 |
+
const mustExist = opts?.mustExist ?? true;
|
| 72 |
+
const label = opts?.label ?? dbPath;
|
| 73 |
+
|
| 74 |
+
if (mustExist) {
|
| 75 |
+
await assertDbExists(
|
| 76 |
+
dbPath,
|
| 77 |
+
opts?.notFound ?? {
|
| 78 |
+
code: "session_not_found",
|
| 79 |
+
message: `库不存在: ${label}`,
|
| 80 |
+
},
|
| 81 |
+
);
|
| 82 |
+
} else {
|
| 83 |
+
await mkdir(dirname(dbPath), { recursive: true });
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
let agent: OpenAgent;
|
| 87 |
+
try {
|
| 88 |
+
agent = await AgentFS.open({ path: dbPath });
|
| 89 |
+
} catch (err) {
|
| 90 |
+
throw mapAgentFsOpenError(err, label);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
try {
|
| 94 |
+
return await fn(agent);
|
| 95 |
+
} finally {
|
| 96 |
+
await closeQuietly(agent);
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
/** 打开 session 库,执行回调后关闭(要求 session 已存在)。 */
|
| 101 |
+
export async function withAgentFs<T>(
|
| 102 |
+
sessionId: string,
|
| 103 |
+
fn: (agent: OpenAgent) => Promise<T>,
|
| 104 |
+
): Promise<T> {
|
| 105 |
+
const dbPath = resolveSessionDbPath(sessionId);
|
| 106 |
+
return withAgentFsAtPath(dbPath, fn, {
|
| 107 |
+
mustExist: true,
|
| 108 |
+
label: sessionId,
|
| 109 |
+
notFound: {
|
| 110 |
+
code: "session_not_found",
|
| 111 |
+
message: `session 不存在: ${sessionId}`,
|
| 112 |
+
},
|
| 113 |
+
});
|
| 114 |
+
}
|
agent/lib/agentfs-debug/paths.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 调试 API 的路径解析与校验。
|
| 3 |
+
*
|
| 4 |
+
* 数据布局复用 `lib/agentfs/paths.ts`;此处补 session id / 虚拟路径 / checkpoint 校验。
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
import { basename, join, resolve } from "node:path";
|
| 8 |
+
|
| 9 |
+
import {
|
| 10 |
+
AGENTFS_BACKEND_NAME,
|
| 11 |
+
resolveDataDir as resolveDataDirFromAppRoot,
|
| 12 |
+
resolveSessionsDir as resolveSessionsDirFromDataDir,
|
| 13 |
+
} from "../agentfs/paths.js";
|
| 14 |
+
import { CHECKPOINT_NAME_RE, SESSION_ID_RE } from "./constants.js";
|
| 15 |
+
import { AgentFsDebugError } from "./errors.js";
|
| 16 |
+
|
| 17 |
+
export { AGENTFS_BACKEND_NAME };
|
| 18 |
+
|
| 19 |
+
/** 数据目录根:`EVEAGENT_DATA_DIR` 优先,否则 `<cwd>/data`。 */
|
| 20 |
+
export function resolveDataDir(): string {
|
| 21 |
+
return resolveDataDirFromAppRoot();
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
/** sessions 目录绝对路径。 */
|
| 25 |
+
export function resolveSessionsDir(): string {
|
| 26 |
+
return resolveSessionsDirFromDataDir(resolveDataDir());
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
/** 校验 session id,防止路径穿越。 */
|
| 30 |
+
export function assertSessionId(id: string): string {
|
| 31 |
+
if (!SESSION_ID_RE.test(id)) {
|
| 32 |
+
throw new AgentFsDebugError(400, "invalid_session_id", "session id 非法");
|
| 33 |
+
}
|
| 34 |
+
return id;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
/**
|
| 38 |
+
* 解析虚拟 FS 路径:必须为绝对路径、无 `..`、无空字节。
|
| 39 |
+
* 空或 `/` → `/`。
|
| 40 |
+
*/
|
| 41 |
+
export function normalizeVirtualPath(raw: string | null | undefined): string {
|
| 42 |
+
const input = (raw ?? "/").trim() || "/";
|
| 43 |
+
if (input.includes("\0")) {
|
| 44 |
+
throw new AgentFsDebugError(400, "invalid_path", "路径含非法字符");
|
| 45 |
+
}
|
| 46 |
+
const withSlash = input.startsWith("/") ? input : `/${input}`;
|
| 47 |
+
const parts = withSlash.split("/").filter((p) => p.length > 0);
|
| 48 |
+
const out: string[] = [];
|
| 49 |
+
for (const part of parts) {
|
| 50 |
+
if (part === ".") continue;
|
| 51 |
+
if (part === "..") {
|
| 52 |
+
throw new AgentFsDebugError(400, "invalid_path", "路径不允许包含 ..");
|
| 53 |
+
}
|
| 54 |
+
out.push(part);
|
| 55 |
+
}
|
| 56 |
+
return out.length === 0 ? "/" : `/${out.join("/")}`;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
/** 将 session id 解析为磁盘上的 .db 绝对路径(id 已禁止路径分隔符)。 */
|
| 60 |
+
export function resolveSessionDbPath(sessionId: string): string {
|
| 61 |
+
const id = assertSessionId(sessionId);
|
| 62 |
+
const sessionsDir = resolveSessionsDir();
|
| 63 |
+
const dbPath = resolve(join(sessionsDir, `${id}.db`));
|
| 64 |
+
if (basename(dbPath) !== `${id}.db`) {
|
| 65 |
+
throw new AgentFsDebugError(400, "invalid_session_id", "session 路径越界");
|
| 66 |
+
}
|
| 67 |
+
return dbPath;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
/** checkpoints 目录:`<sessionsDir>/checkpoints/<sessionId>/` */
|
| 71 |
+
export function resolveCheckpointsDir(sessionId: string): string {
|
| 72 |
+
return join(resolveSessionsDir(), "checkpoints", assertSessionId(sessionId));
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
/** 单个存档点 .db 文件路径。 */
|
| 76 |
+
export function resolveCheckpointDbPath(
|
| 77 |
+
sessionId: string,
|
| 78 |
+
name: string,
|
| 79 |
+
): string {
|
| 80 |
+
if (!CHECKPOINT_NAME_RE.test(name)) {
|
| 81 |
+
throw new AgentFsDebugError(
|
| 82 |
+
400,
|
| 83 |
+
"invalid_checkpoint_name",
|
| 84 |
+
"存档点名非法(允许中文、字母、数字、._-)",
|
| 85 |
+
);
|
| 86 |
+
}
|
| 87 |
+
return join(resolveCheckpointsDir(sessionId), `${name}.db`);
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
/** 拼接虚拟目录下的子路径。 */
|
| 91 |
+
export function joinVirtualPath(dir: string, name: string): string {
|
| 92 |
+
return dir === "/" ? `/${name}` : `${dir}/${name}`;
|
| 93 |
+
}
|
agent/lib/agentfs-debug/sessions.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 列出磁盘上的 session 沙箱库。
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import { readdir, stat } from "node:fs/promises";
|
| 6 |
+
import { join } from "node:path";
|
| 7 |
+
|
| 8 |
+
import { SESSION_ID_RE } from "./constants.js";
|
| 9 |
+
import { resolveSessionsDir } from "./paths.js";
|
| 10 |
+
import type { SessionDbInfo } from "./types.js";
|
| 11 |
+
|
| 12 |
+
/** 列出 sessions 目录下的 `*.db`(忽略 -wal/-shm 旁路文件)。 */
|
| 13 |
+
export async function listSessionDbs(): Promise<SessionDbInfo[]> {
|
| 14 |
+
const sessionsDir = resolveSessionsDir();
|
| 15 |
+
let names: string[];
|
| 16 |
+
try {
|
| 17 |
+
names = await readdir(sessionsDir);
|
| 18 |
+
} catch (err) {
|
| 19 |
+
const code = (err as NodeJS.ErrnoException).code;
|
| 20 |
+
if (code === "ENOENT") return [];
|
| 21 |
+
throw err;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
const out: SessionDbInfo[] = [];
|
| 25 |
+
for (const name of names) {
|
| 26 |
+
// 只认主库文件名:foo.db;foo.db-wal / foo.db-shm 不以 .db 结尾
|
| 27 |
+
if (!name.endsWith(".db")) continue;
|
| 28 |
+
const id = name.slice(0, -".db".length);
|
| 29 |
+
if (!SESSION_ID_RE.test(id)) continue;
|
| 30 |
+
const dbPath = join(sessionsDir, name);
|
| 31 |
+
try {
|
| 32 |
+
const st = await stat(dbPath);
|
| 33 |
+
if (!st.isFile()) continue;
|
| 34 |
+
out.push({
|
| 35 |
+
id,
|
| 36 |
+
dbPath,
|
| 37 |
+
sizeBytes: st.size,
|
| 38 |
+
mtimeMs: st.mtimeMs,
|
| 39 |
+
});
|
| 40 |
+
} catch {
|
| 41 |
+
// 单文件 stat 失败则跳过
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
out.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
| 45 |
+
return out;
|
| 46 |
+
}
|
agent/lib/agentfs-debug/types.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* AgentFS 调试 API 的公开类型。
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
export type SessionDbInfo = {
|
| 6 |
+
id: string;
|
| 7 |
+
dbPath: string;
|
| 8 |
+
sizeBytes: number;
|
| 9 |
+
mtimeMs: number;
|
| 10 |
+
};
|
| 11 |
+
|
| 12 |
+
export type TreeEntry = {
|
| 13 |
+
name: string;
|
| 14 |
+
path: string;
|
| 15 |
+
type: "file" | "directory" | "symlink" | "other";
|
| 16 |
+
size: number;
|
| 17 |
+
mtime: number;
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
export type FilePreview = {
|
| 21 |
+
path: string;
|
| 22 |
+
size: number;
|
| 23 |
+
mtime: number;
|
| 24 |
+
encoding: "utf-8" | "binary";
|
| 25 |
+
truncated: boolean;
|
| 26 |
+
content: string;
|
| 27 |
+
byteLength: number;
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
export type CheckpointInfo = {
|
| 31 |
+
name: string;
|
| 32 |
+
sizeBytes: number;
|
| 33 |
+
mtimeMs: number;
|
| 34 |
+
};
|
agent/lib/agentfs-sandbox/backend.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* AgentFS + just-bash 的 `SandboxBackend` 实现。
|
| 3 |
+
*
|
| 4 |
+
* 生命周期对齐 eve 官方 just-bash 后端:
|
| 5 |
+
* - prewarm:按 templateKey 打开/复用模板库;可跑 bootstrap 与 seedFiles
|
| 6 |
+
* - create :每个 durable session 独立 .db;优先 existingMetadata / 进程内缓存,
|
| 7 |
+
* 否则从模板复制,再否则空建
|
| 8 |
+
* - handle :captureState 写 metadata v1;shutdown 停解释器并踢出缓存
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
import { utimes } from "node:fs/promises";
|
| 12 |
+
|
| 13 |
+
import {
|
| 14 |
+
SandboxTemplateNotProvisionedError,
|
| 15 |
+
type SandboxBackend,
|
| 16 |
+
type SandboxBackendCreateInput,
|
| 17 |
+
type SandboxBackendHandle,
|
| 18 |
+
type SandboxBackendPrewarmInput,
|
| 19 |
+
type SandboxBackendSessionState,
|
| 20 |
+
type SandboxSeedFile,
|
| 21 |
+
type SandboxSession,
|
| 22 |
+
} from "eve/sandbox";
|
| 23 |
+
|
| 24 |
+
import {
|
| 25 |
+
AGENTFS_BACKEND_NAME,
|
| 26 |
+
resolveDataDir,
|
| 27 |
+
resolveSessionDbPath,
|
| 28 |
+
resolveTemplateDbPath,
|
| 29 |
+
} from "../agentfs/paths.js";
|
| 30 |
+
import { hostRegistry, pathExists, type AgentFsHost } from "./host.js";
|
| 31 |
+
import {
|
| 32 |
+
buildSessionMetadata,
|
| 33 |
+
resolveExistingDbPath,
|
| 34 |
+
} from "./metadata.js";
|
| 35 |
+
import { maybePruneStaleSessions } from "./prune.js";
|
| 36 |
+
import {
|
| 37 |
+
buildInternalSession,
|
| 38 |
+
buildPublicSession,
|
| 39 |
+
setNetworkPolicyUnsupported,
|
| 40 |
+
} from "./session.js";
|
| 41 |
+
|
| 42 |
+
async function writeSeedFiles(
|
| 43 |
+
session: SandboxSession,
|
| 44 |
+
seedFiles: ReadonlyArray<SandboxSeedFile>,
|
| 45 |
+
): Promise<void> {
|
| 46 |
+
for (const file of seedFiles) {
|
| 47 |
+
if (typeof file.content === "string") {
|
| 48 |
+
await session.writeTextFile({ path: file.path, content: file.content });
|
| 49 |
+
} else {
|
| 50 |
+
await session.writeBinaryFile({
|
| 51 |
+
path: file.path,
|
| 52 |
+
content: file.content,
|
| 53 |
+
});
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
function buildHandle(opts: {
|
| 59 |
+
host: AgentFsHost;
|
| 60 |
+
sessionKey: string;
|
| 61 |
+
}): SandboxBackendHandle {
|
| 62 |
+
const { host, sessionKey } = opts;
|
| 63 |
+
const internal = buildInternalSession({
|
| 64 |
+
id: sessionKey,
|
| 65 |
+
sandbox: host.sandbox,
|
| 66 |
+
fs: host.fs,
|
| 67 |
+
});
|
| 68 |
+
const session = buildPublicSession(internal, setNetworkPolicyUnsupported);
|
| 69 |
+
|
| 70 |
+
return {
|
| 71 |
+
session,
|
| 72 |
+
useSessionFn: async () => session,
|
| 73 |
+
async captureState(): Promise<SandboxBackendSessionState> {
|
| 74 |
+
return {
|
| 75 |
+
backendName: AGENTFS_BACKEND_NAME,
|
| 76 |
+
sessionKey,
|
| 77 |
+
metadata: buildSessionMetadata(host.dbPath),
|
| 78 |
+
};
|
| 79 |
+
},
|
| 80 |
+
async shutdown(): Promise<void> {
|
| 81 |
+
// 停 just-bash 解释器并踢缓存;.db 留在磁盘供下次 create 重连
|
| 82 |
+
await hostRegistry.shutdown(host.dbPath);
|
| 83 |
+
},
|
| 84 |
+
};
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
/**
|
| 88 |
+
* 解析 create 应打开的 session 库路径,并在需要时从模板复制。
|
| 89 |
+
*
|
| 90 |
+
* 优先级:
|
| 91 |
+
* 1. 进程内同 sessionKey 已登记的 dbPath
|
| 92 |
+
* 2. existingMetadata.dbPath(持久化重连)
|
| 93 |
+
* 3. 默认 sessions/<sessionKey>.db;若不存在且有 templateKey,则从模板复制
|
| 94 |
+
*/
|
| 95 |
+
async function resolveSessionDbForCreate(
|
| 96 |
+
input: SandboxBackendCreateInput,
|
| 97 |
+
): Promise<string> {
|
| 98 |
+
const { runtimeContext, sessionKey, templateKey } = input;
|
| 99 |
+
const dataDir = resolveDataDir(runtimeContext.appRoot);
|
| 100 |
+
|
| 101 |
+
const inProcess = hostRegistry.getDbPathForSession(sessionKey);
|
| 102 |
+
if (inProcess) return inProcess;
|
| 103 |
+
|
| 104 |
+
const fromMetadata = resolveExistingDbPath(input);
|
| 105 |
+
if (fromMetadata) return fromMetadata;
|
| 106 |
+
|
| 107 |
+
const sessionDbPath = resolveSessionDbPath(dataDir, sessionKey);
|
| 108 |
+
if (await pathExists(sessionDbPath)) return sessionDbPath;
|
| 109 |
+
|
| 110 |
+
// 库尚不存在:有模板则复制派生,无模板则空建(open 时创建文件)
|
| 111 |
+
if (templateKey !== null) {
|
| 112 |
+
const templateDbPath = resolveTemplateDbPath(dataDir, templateKey);
|
| 113 |
+
if (!(await pathExists(templateDbPath))) {
|
| 114 |
+
throw new SandboxTemplateNotProvisionedError({
|
| 115 |
+
backendName: AGENTFS_BACKEND_NAME,
|
| 116 |
+
templateKey,
|
| 117 |
+
});
|
| 118 |
+
}
|
| 119 |
+
await hostRegistry.copyTemplateDb(templateDbPath, sessionDbPath);
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
return sessionDbPath;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
/** 构造 agentfs-just-bash SandboxBackend。 */
|
| 126 |
+
export function createAgentFsJustBashBackend(): SandboxBackend {
|
| 127 |
+
return {
|
| 128 |
+
name: AGENTFS_BACKEND_NAME,
|
| 129 |
+
|
| 130 |
+
async prewarm(
|
| 131 |
+
input: SandboxBackendPrewarmInput,
|
| 132 |
+
): Promise<{ reused: boolean }> {
|
| 133 |
+
const { runtimeContext, templateKey, bootstrap, seedFiles, log } = input;
|
| 134 |
+
const dataDir = resolveDataDir(runtimeContext.appRoot);
|
| 135 |
+
const dbPath = resolveTemplateDbPath(dataDir, templateKey);
|
| 136 |
+
|
| 137 |
+
const existing = hostRegistry.getByDbPath(dbPath);
|
| 138 |
+
if (existing || (await pathExists(dbPath))) {
|
| 139 |
+
// touch 宿主文件 mtime,给 prune/运维「最近活跃模板」信号
|
| 140 |
+
try {
|
| 141 |
+
const now = new Date();
|
| 142 |
+
await utimes(dbPath, now, now);
|
| 143 |
+
} catch {
|
| 144 |
+
// 文件尚被创建中或平台差异——无害
|
| 145 |
+
}
|
| 146 |
+
if (!existing) {
|
| 147 |
+
await hostRegistry.getOrOpen({ dbPath });
|
| 148 |
+
}
|
| 149 |
+
return { reused: true };
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
const host = await hostRegistry.getOrOpen({ dbPath });
|
| 153 |
+
const internal = buildInternalSession({
|
| 154 |
+
id: templateKey,
|
| 155 |
+
sandbox: host.sandbox,
|
| 156 |
+
fs: host.fs,
|
| 157 |
+
});
|
| 158 |
+
const session = buildPublicSession(internal, setNetworkPolicyUnsupported);
|
| 159 |
+
|
| 160 |
+
if (bootstrap) {
|
| 161 |
+
log?.("running sandbox bootstrap");
|
| 162 |
+
await bootstrap({ use: async () => session });
|
| 163 |
+
}
|
| 164 |
+
if (seedFiles.length > 0) {
|
| 165 |
+
await writeSeedFiles(session, seedFiles);
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
return { reused: false };
|
| 169 |
+
},
|
| 170 |
+
|
| 171 |
+
async create(
|
| 172 |
+
input: SandboxBackendCreateInput,
|
| 173 |
+
): Promise<SandboxBackendHandle> {
|
| 174 |
+
await maybePruneStaleSessions(
|
| 175 |
+
input.runtimeContext.appRoot,
|
| 176 |
+
hostRegistry,
|
| 177 |
+
);
|
| 178 |
+
|
| 179 |
+
const dbPath = await resolveSessionDbForCreate(input);
|
| 180 |
+
const host = await hostRegistry.getOrOpen({
|
| 181 |
+
dbPath,
|
| 182 |
+
sessionKey: input.sessionKey,
|
| 183 |
+
});
|
| 184 |
+
return buildHandle({ host, sessionKey: input.sessionKey });
|
| 185 |
+
},
|
| 186 |
+
};
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
export { AGENTFS_BACKEND_NAME };
|
agent/lib/agentfs-sandbox/host.ts
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* AgentFS + just-bash 进程级 host:打开、缓存、派生、独占借用。
|
| 3 |
+
*
|
| 4 |
+
* 键用 dbPath(不是 templateKey/sessionKey)——同一路径命中复用,避免重复 open SQLite。
|
| 5 |
+
* session 另记 sessionKey → dbPath,供 prune 跳过活跃库。
|
| 6 |
+
*
|
| 7 |
+
* 生命周期契约:
|
| 8 |
+
* - getOrOpen / shutdown / withExclusiveAccess 对同一 dbPath 串行
|
| 9 |
+
* - 任何会大范围改写 live session 库的路径(checkpoint create/restore)必须
|
| 10 |
+
* withExclusiveAccess:先 evict(stop sandbox + close AgentFS),再独占 open,
|
| 11 |
+
* 结束后不自动重开;下次 getOrOpen 以磁盘最新内容重建 host
|
| 12 |
+
*/
|
| 13 |
+
|
| 14 |
+
import { AsyncLocalStorage } from "node:async_hooks";
|
| 15 |
+
import { access, copyFile, mkdir } from "node:fs/promises";
|
| 16 |
+
import { dirname } from "node:path";
|
| 17 |
+
|
| 18 |
+
import { AgentFS } from "agentfs-sdk";
|
| 19 |
+
import { AgentFsWrapper as JustBashFsWrapper } from "agentfs-sdk/just-bash";
|
| 20 |
+
import {
|
| 21 |
+
Sandbox as JustBashSandbox,
|
| 22 |
+
type IFileSystem,
|
| 23 |
+
} from "just-bash";
|
| 24 |
+
|
| 25 |
+
import { WORKSPACE_ROOT } from "../agentfs/paths.js";
|
| 26 |
+
import { padJustBashFs } from "./pad-fs.js";
|
| 27 |
+
|
| 28 |
+
export type JustBashSandboxInstance = Awaited<
|
| 29 |
+
ReturnType<typeof JustBashSandbox.create>
|
| 30 |
+
>;
|
| 31 |
+
|
| 32 |
+
export type OpenAgentFs = Awaited<ReturnType<typeof AgentFS.open>>;
|
| 33 |
+
|
| 34 |
+
export interface AgentFsHost {
|
| 35 |
+
agentFs: OpenAgentFs;
|
| 36 |
+
sandbox: JustBashSandboxInstance;
|
| 37 |
+
fs: IFileSystem;
|
| 38 |
+
dbPath: string;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
/** websearch 子 agent 需要的出站前缀;其余默认拒绝。 */
|
| 42 |
+
const NETWORK_ALLOWED_URL_PREFIXES = [
|
| 43 |
+
"https://api.anysearch.com/",
|
| 44 |
+
"https://api.firecrawl.dev/",
|
| 45 |
+
] as const;
|
| 46 |
+
|
| 47 |
+
const NETWORK_ALLOWED_METHODS = ["GET", "HEAD", "POST"] as const;
|
| 48 |
+
|
| 49 |
+
/** 仅透传检索相关密钥进沙箱 env,不全量开放 process.env。 */
|
| 50 |
+
function buildSandboxEnv(): Record<string, string> {
|
| 51 |
+
const env: Record<string, string> = {};
|
| 52 |
+
for (const key of ["ANYSEARCH_API_KEY", "FIRECRAWL_API_KEY"] as const) {
|
| 53 |
+
const v = process.env[key]?.trim();
|
| 54 |
+
if (v) env[key] = v;
|
| 55 |
+
}
|
| 56 |
+
return env;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
async function pathExists(path: string): Promise<boolean> {
|
| 60 |
+
try {
|
| 61 |
+
await access(path);
|
| 62 |
+
return true;
|
| 63 |
+
} catch {
|
| 64 |
+
return false;
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
async function closeAgentFsQuietly(agentFs: OpenAgentFs): Promise<void> {
|
| 69 |
+
try {
|
| 70 |
+
await agentFs.close();
|
| 71 |
+
} catch {
|
| 72 |
+
// 关闭失败不阻塞驱逐
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
/**
|
| 77 |
+
* 进程级 host 注册表。
|
| 78 |
+
* 对外暴露 open / get / shutdown / withExclusiveAccess;不在模块顶层散落 Map。
|
| 79 |
+
*/
|
| 80 |
+
export class HostRegistry {
|
| 81 |
+
private readonly hostsByDbPath = new Map<string, AgentFsHost>();
|
| 82 |
+
private readonly sessionKeyToDbPath = new Map<string, string>();
|
| 83 |
+
/** 同一 dbPath 上 getOrOpen / shutdown / exclusive 的串行尾。 */
|
| 84 |
+
private readonly pathChains = new Map<string, Promise<unknown>>();
|
| 85 |
+
/**
|
| 86 |
+
* 当前异步上下文正持有串行锁的 dbPath。
|
| 87 |
+
* 同路径重入(exclusive 内部再调 getOrOpen 等)直接执行,避免自死锁。
|
| 88 |
+
*/
|
| 89 |
+
private readonly activeDbPath = new AsyncLocalStorage<string>();
|
| 90 |
+
|
| 91 |
+
getByDbPath(dbPath: string): AgentFsHost | undefined {
|
| 92 |
+
return this.hostsByDbPath.get(dbPath);
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
getDbPathForSession(sessionKey: string): string | undefined {
|
| 96 |
+
return this.sessionKeyToDbPath.get(sessionKey);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
hasActiveSession(sessionKey: string): boolean {
|
| 100 |
+
return this.sessionKeyToDbPath.has(sessionKey);
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
/**
|
| 104 |
+
* 在同一 dbPath 上串行执行。同上下文重入直接跑 fn。
|
| 105 |
+
* 链上错误不污染后续任务。
|
| 106 |
+
*/
|
| 107 |
+
private runSerialized<T>(dbPath: string, fn: () => Promise<T>): Promise<T> {
|
| 108 |
+
if (this.activeDbPath.getStore() === dbPath) {
|
| 109 |
+
return fn();
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
const prev = this.pathChains.get(dbPath) ?? Promise.resolve();
|
| 113 |
+
const run = async (): Promise<T> => this.activeDbPath.run(dbPath, fn);
|
| 114 |
+
// 前序失败也继续本任务
|
| 115 |
+
const next = prev.then(run, run);
|
| 116 |
+
this.pathChains.set(
|
| 117 |
+
dbPath,
|
| 118 |
+
next.then(
|
| 119 |
+
() => undefined,
|
| 120 |
+
() => undefined,
|
| 121 |
+
),
|
| 122 |
+
);
|
| 123 |
+
return next;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
/**
|
| 127 |
+
* 打开(命中复用)一个 session 或 template 的 AgentFS + just-bash 组合。
|
| 128 |
+
* sessionKey 非空时登记反向索引,供 prune 跳过活跃库。
|
| 129 |
+
* 与 exclusive / shutdown 同路径串行。
|
| 130 |
+
*/
|
| 131 |
+
async getOrOpen(opts: {
|
| 132 |
+
dbPath: string;
|
| 133 |
+
sessionKey?: string;
|
| 134 |
+
}): Promise<AgentFsHost> {
|
| 135 |
+
return this.runSerialized(opts.dbPath, () => this.getOrOpenUnlocked(opts));
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
private async getOrOpenUnlocked(opts: {
|
| 139 |
+
dbPath: string;
|
| 140 |
+
sessionKey?: string;
|
| 141 |
+
}): Promise<AgentFsHost> {
|
| 142 |
+
const { dbPath, sessionKey } = opts;
|
| 143 |
+
const cached = this.hostsByDbPath.get(dbPath);
|
| 144 |
+
if (cached) {
|
| 145 |
+
if (sessionKey) this.sessionKeyToDbPath.set(sessionKey, dbPath);
|
| 146 |
+
return cached;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
await mkdir(dirname(dbPath), { recursive: true });
|
| 150 |
+
const agentFs = await AgentFS.open({ path: dbPath });
|
| 151 |
+
|
| 152 |
+
// 直接构造 AgentFsWrapper:agentfs() 工厂在 Node 24 + pnpm 软链下
|
| 153 |
+
// 类型守卫会走错兜底分支,触发二次 open 报错。
|
| 154 |
+
const wrapped = padJustBashFs(
|
| 155 |
+
new JustBashFsWrapper({ fs: agentFs }) as unknown as IFileSystem,
|
| 156 |
+
);
|
| 157 |
+
|
| 158 |
+
const sandbox = await JustBashSandbox.create({
|
| 159 |
+
cwd: WORKSPACE_ROOT,
|
| 160 |
+
env: buildSandboxEnv(),
|
| 161 |
+
fs: wrapped,
|
| 162 |
+
network: {
|
| 163 |
+
allowedUrlPrefixes: [...NETWORK_ALLOWED_URL_PREFIXES],
|
| 164 |
+
allowedMethods: [...NETWORK_ALLOWED_METHODS],
|
| 165 |
+
},
|
| 166 |
+
});
|
| 167 |
+
|
| 168 |
+
// Sandbox.create 不会自动建 /workspace
|
| 169 |
+
try {
|
| 170 |
+
await sandbox.mkDir(WORKSPACE_ROOT, { recursive: true });
|
| 171 |
+
} catch {
|
| 172 |
+
// 已存在可忽略
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
const host: AgentFsHost = { agentFs, sandbox, fs: wrapped, dbPath };
|
| 176 |
+
this.hostsByDbPath.set(dbPath, host);
|
| 177 |
+
if (sessionKey) this.sessionKeyToDbPath.set(sessionKey, dbPath);
|
| 178 |
+
return host;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
/**
|
| 182 |
+
* 踢掉缓存中的 host:stop just-bash + close AgentFS,清 session 反向索引。
|
| 183 |
+
* 磁盘 .db 保留。须在已持有该路径串行锁时调用。
|
| 184 |
+
*/
|
| 185 |
+
private async evictUnlocked(dbPath: string): Promise<void> {
|
| 186 |
+
const host = this.hostsByDbPath.get(dbPath);
|
| 187 |
+
if (!host) return;
|
| 188 |
+
|
| 189 |
+
this.hostsByDbPath.delete(dbPath);
|
| 190 |
+
for (const [key, path] of this.sessionKeyToDbPath) {
|
| 191 |
+
if (path === dbPath) this.sessionKeyToDbPath.delete(key);
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
try {
|
| 195 |
+
await host.sandbox.stop();
|
| 196 |
+
} catch {
|
| 197 |
+
// stop 失败不阻塞驱逐
|
| 198 |
+
}
|
| 199 |
+
// 必须 close:否则 exclusive 二次 open 与残留连接双写 / 视图分裂
|
| 200 |
+
await closeAgentFsQuietly(host.agentFs);
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
/** 停掉 just-bash 解释器、关闭 AgentFS,并从缓存剔除;磁盘上的 .db 保留以便重连。 */
|
| 204 |
+
async shutdown(dbPath: string): Promise<void> {
|
| 205 |
+
return this.runSerialized(dbPath, () => this.evictUnlocked(dbPath));
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
/**
|
| 209 |
+
* 独占访问某 .db:先 evict live host,再执行 fn(fn 内可安全独立 open 该路径)。
|
| 210 |
+
* 与 getOrOpen 同路径串行,杜绝「host 占着 + debug 再开」的双开。
|
| 211 |
+
* fn 结束后不自动重开 host——下次 getOrOpen 以磁盘最新内容重建。
|
| 212 |
+
*
|
| 213 |
+
* 注意:已下发的 SandboxBackendHandle 仍持有旧 host 引用;
|
| 214 |
+
* exclusive 后旧连接会失败或不可用,这是刻意的(避免工具继续看回滚前视图)。
|
| 215 |
+
* 管理侧 restore 期间中断 mid-flight 工具调用是可接受代价。
|
| 216 |
+
*/
|
| 217 |
+
async withExclusiveAccess<T>(
|
| 218 |
+
dbPath: string,
|
| 219 |
+
fn: () => Promise<T>,
|
| 220 |
+
): Promise<T> {
|
| 221 |
+
return this.runSerialized(dbPath, async () => {
|
| 222 |
+
await this.evictUnlocked(dbPath);
|
| 223 |
+
return await fn();
|
| 224 |
+
});
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
/**
|
| 228 |
+
* 把模板库文件复制到 session 路径(session 尚不存在时)。
|
| 229 |
+
* 仅复制主 .db;wal/shm 由后续 open 重建。
|
| 230 |
+
*/
|
| 231 |
+
async copyTemplateDb(
|
| 232 |
+
templateDbPath: string,
|
| 233 |
+
sessionDbPath: string,
|
| 234 |
+
): Promise<void> {
|
| 235 |
+
if (!(await pathExists(templateDbPath))) {
|
| 236 |
+
throw new Error(`template database missing: ${templateDbPath}`);
|
| 237 |
+
}
|
| 238 |
+
await mkdir(dirname(sessionDbPath), { recursive: true });
|
| 239 |
+
await copyFile(templateDbPath, sessionDbPath);
|
| 240 |
+
}
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
/** 进程单例:同一 Node 进程内所有 eve session 共享。 */
|
| 244 |
+
export const hostRegistry = new HostRegistry();
|
| 245 |
+
|
| 246 |
+
export { pathExists };
|
agent/lib/agentfs-sandbox/metadata.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* sandbox-backend metadata schema v1 校验。
|
| 3 |
+
* 持久化字段:`{ version: 1, dbPath: string }`。
|
| 4 |
+
* 版本不匹配或字段缺失一律丢弃(记 warn),强制按 sessionKey 重建路径。
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
import type { SandboxBackendCreateInput } from "eve/sandbox";
|
| 8 |
+
import { AGENTFS_BACKEND_NAME } from "../agentfs/paths.js";
|
| 9 |
+
|
| 10 |
+
export const METADATA_VERSION = 1 as const;
|
| 11 |
+
|
| 12 |
+
export type SessionMetadataV1 = {
|
| 13 |
+
readonly version: typeof METADATA_VERSION;
|
| 14 |
+
readonly dbPath: string;
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
export function buildSessionMetadata(dbPath: string): SessionMetadataV1 {
|
| 18 |
+
return { version: METADATA_VERSION, dbPath };
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
/** 从 existingMetadata 严格解出 dbPath;不合法返回 null。 */
|
| 22 |
+
export function resolveExistingDbPath(
|
| 23 |
+
input: SandboxBackendCreateInput,
|
| 24 |
+
): string | null {
|
| 25 |
+
const m = input.existingMetadata as Record<string, unknown> | undefined;
|
| 26 |
+
if (!m) return null;
|
| 27 |
+
|
| 28 |
+
if (m.version !== METADATA_VERSION) {
|
| 29 |
+
if (typeof m.dbPath === "string") {
|
| 30 |
+
console.warn(
|
| 31 |
+
`[${AGENTFS_BACKEND_NAME}] dropping legacy metadata dbPath=${m.dbPath}; ` +
|
| 32 |
+
`rebuilding session ${input.sessionKey} from scratch`,
|
| 33 |
+
);
|
| 34 |
+
}
|
| 35 |
+
return null;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
if (typeof m.dbPath !== "string" || m.dbPath.length === 0) {
|
| 39 |
+
return null;
|
| 40 |
+
}
|
| 41 |
+
return m.dbPath;
|
| 42 |
+
}
|
agent/lib/agentfs-sandbox/pad-fs.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 补全 AgentFsWrapper 相对 just-bash `IFileSystem` 缺失的必选方法。
|
| 3 |
+
*
|
| 4 |
+
* AgentFsWrapper 已实现 readFileBuffer / appendFile / getAllPaths / chmod 等;
|
| 5 |
+
* 缺的是 IFileSystem 契约要求的 `realpath` 与 `utimes`。
|
| 6 |
+
* just-bash 会直接 await 这两个方法(realpath 在 try 里,utimes 在 touch 类命令里),
|
| 7 |
+
* 未定义会变成 "is not a function"。
|
| 8 |
+
*
|
| 9 |
+
* 故意不补半残的 optional 方法(如只有 name 的 readdirWithFileTypes):
|
| 10 |
+
* 缺省时 just-bash 会走 readdir + stat 回退,比错误的 type 信息更安全。
|
| 11 |
+
*/
|
| 12 |
+
|
| 13 |
+
import type { IFileSystem } from "just-bash";
|
| 14 |
+
|
| 15 |
+
/** AgentFS 不支持真实 symlink 解析与 mtime 写入时的最小 stub。 */
|
| 16 |
+
export function padJustBashFs(inner: IFileSystem): IFileSystem {
|
| 17 |
+
const realpath = async (path: string): Promise<string> => path;
|
| 18 |
+
const utimes = async (
|
| 19 |
+
_path: string,
|
| 20 |
+
_atime: Date,
|
| 21 |
+
_mtime: Date,
|
| 22 |
+
): Promise<void> => {
|
| 23 |
+
// AgentFS 不支持 utimes;noop 即可
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
return Object.assign(Object.create(inner) as IFileSystem, {
|
| 27 |
+
realpath,
|
| 28 |
+
utimes,
|
| 29 |
+
});
|
| 30 |
+
}
|
agent/lib/agentfs-sandbox/process.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 把 just-bash `Command`(单路 `logs()` 异步迭代)适配为 eve `SandboxProcess`
|
| 3 |
+
*(独立的 stdout / stderr `ReadableStream`)。
|
| 4 |
+
*
|
| 5 |
+
* 与 eve 内部 `adaptMultiplexedCommandToSandboxProcess` 同构:
|
| 6 |
+
* 只消费一次 logs,边收边 fan-out,避免双消费者竞态,也避免整段缓冲后再吐。
|
| 7 |
+
*/
|
| 8 |
+
|
| 9 |
+
import type { SandboxProcess } from "eve/sandbox";
|
| 10 |
+
|
| 11 |
+
export interface MultiplexedCommand {
|
| 12 |
+
logs(): AsyncIterable<{ type: "stdout" | "stderr"; data: string }>;
|
| 13 |
+
wait(): PromiseLike<{ exitCode: number }>;
|
| 14 |
+
kill(): PromiseLike<void> | void;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
function createOutputChannel(): {
|
| 18 |
+
stream: ReadableStream<Uint8Array>;
|
| 19 |
+
enqueue(chunk: Uint8Array): void;
|
| 20 |
+
close(): void;
|
| 21 |
+
error(err: unknown): void;
|
| 22 |
+
} {
|
| 23 |
+
let cancelled = false;
|
| 24 |
+
let controller: ReadableStreamDefaultController<Uint8Array> | undefined;
|
| 25 |
+
return {
|
| 26 |
+
stream: new ReadableStream<Uint8Array>({
|
| 27 |
+
start(c) {
|
| 28 |
+
controller = c;
|
| 29 |
+
},
|
| 30 |
+
cancel() {
|
| 31 |
+
cancelled = true;
|
| 32 |
+
},
|
| 33 |
+
}),
|
| 34 |
+
enqueue(chunk) {
|
| 35 |
+
if (!cancelled) controller?.enqueue(chunk);
|
| 36 |
+
},
|
| 37 |
+
close() {
|
| 38 |
+
if (!cancelled) controller?.close();
|
| 39 |
+
},
|
| 40 |
+
error(err) {
|
| 41 |
+
if (!cancelled) controller?.error(err);
|
| 42 |
+
},
|
| 43 |
+
};
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
/** 单次消费 logs,按 type 拆到两条流;wait 等日志泵完成后再返回 exitCode。 */
|
| 47 |
+
export function adaptCommandToSandboxProcess(
|
| 48 |
+
command: MultiplexedCommand,
|
| 49 |
+
): SandboxProcess {
|
| 50 |
+
const encoder = new TextEncoder();
|
| 51 |
+
const stdout = createOutputChannel();
|
| 52 |
+
const stderr = createOutputChannel();
|
| 53 |
+
const channels = { stdout, stderr } as const;
|
| 54 |
+
|
| 55 |
+
const pump = (async () => {
|
| 56 |
+
try {
|
| 57 |
+
for await (const msg of command.logs()) {
|
| 58 |
+
channels[msg.type].enqueue(encoder.encode(msg.data));
|
| 59 |
+
}
|
| 60 |
+
stdout.close();
|
| 61 |
+
stderr.close();
|
| 62 |
+
} catch (err) {
|
| 63 |
+
stdout.error(err);
|
| 64 |
+
stderr.error(err);
|
| 65 |
+
throw err;
|
| 66 |
+
}
|
| 67 |
+
})();
|
| 68 |
+
// 无消费者时避免未处理 rejection
|
| 69 |
+
pump.catch(() => undefined);
|
| 70 |
+
|
| 71 |
+
let waitPromise: Promise<{ exitCode: number }> | undefined;
|
| 72 |
+
let killPromise: Promise<void> | undefined;
|
| 73 |
+
|
| 74 |
+
return {
|
| 75 |
+
stdout: stdout.stream,
|
| 76 |
+
stderr: stderr.stream,
|
| 77 |
+
wait() {
|
| 78 |
+
return (waitPromise ??= Promise.resolve().then(async () => {
|
| 79 |
+
const result = await command.wait();
|
| 80 |
+
await pump;
|
| 81 |
+
return { exitCode: result.exitCode };
|
| 82 |
+
}));
|
| 83 |
+
},
|
| 84 |
+
kill() {
|
| 85 |
+
return (killPromise ??= Promise.resolve().then(() => command.kill()));
|
| 86 |
+
},
|
| 87 |
+
};
|
| 88 |
+
}
|
agent/lib/agentfs-sandbox/prune.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 清理 sessions 目录下 mtime 超过 AGENTFS_SESSION_MAX_AGE_DAYS 的 .db。
|
| 3 |
+
* - 跳过 HostRegistry 仍在引用的活跃 session
|
| 4 |
+
* - 不动 templates/(删错要重 build)
|
| 5 |
+
* - 第一次 open session 时 lazy 触发一次,避免硬编码 Nitro 启动 hook
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
import { readdir, rm, stat } from "node:fs/promises";
|
| 9 |
+
import { join } from "node:path";
|
| 10 |
+
|
| 11 |
+
import {
|
| 12 |
+
AGENTFS_BACKEND_NAME,
|
| 13 |
+
resolveDataDir,
|
| 14 |
+
resolveSessionsDir,
|
| 15 |
+
} from "../agentfs/paths.js";
|
| 16 |
+
import type { HostRegistry } from "./host.js";
|
| 17 |
+
|
| 18 |
+
let prunePromise: Promise<void> | null = null;
|
| 19 |
+
|
| 20 |
+
/** 进程内只跑一轮 stale prune(失败不阻塞 create)。 */
|
| 21 |
+
export function maybePruneStaleSessions(
|
| 22 |
+
appRoot: string,
|
| 23 |
+
registry: HostRegistry,
|
| 24 |
+
): Promise<void> {
|
| 25 |
+
if (prunePromise) return prunePromise;
|
| 26 |
+
prunePromise = pruneStaleSessions(appRoot, registry).catch((err) => {
|
| 27 |
+
console.warn(`[${AGENTFS_BACKEND_NAME}] stale-session prune failed:`, err);
|
| 28 |
+
});
|
| 29 |
+
return prunePromise;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
export async function pruneStaleSessions(
|
| 33 |
+
appRoot: string,
|
| 34 |
+
registry: HostRegistry,
|
| 35 |
+
): Promise<void> {
|
| 36 |
+
const dataDir = resolveDataDir(appRoot);
|
| 37 |
+
const sessionsDir = resolveSessionsDir(dataDir);
|
| 38 |
+
|
| 39 |
+
let entries: import("node:fs").Dirent[];
|
| 40 |
+
try {
|
| 41 |
+
entries = await readdir(sessionsDir, { withFileTypes: true });
|
| 42 |
+
} catch {
|
| 43 |
+
// 目录不存在(首次启动)
|
| 44 |
+
return;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
const maxAgeDays = Number(process.env.AGENTFS_SESSION_MAX_AGE_DAYS ?? 30);
|
| 48 |
+
if (!Number.isFinite(maxAgeDays) || maxAgeDays <= 0) return;
|
| 49 |
+
|
| 50 |
+
const maxAgeMs = maxAgeDays * 86_400_000;
|
| 51 |
+
const now = Date.now();
|
| 52 |
+
|
| 53 |
+
for (const entry of entries) {
|
| 54 |
+
if (!entry.isFile() || !entry.name.endsWith(".db")) continue;
|
| 55 |
+
// 忽略 wal/shm 旁车(名字以 .db-wal 结尾的不会 match .db 精确后缀之外的情况;
|
| 56 |
+
// 这里 name.endsWith(".db") 已排除 .db-wal / .db-shm)
|
| 57 |
+
const sessionKey = entry.name.slice(0, -".db".length);
|
| 58 |
+
if (registry.hasActiveSession(sessionKey)) continue;
|
| 59 |
+
|
| 60 |
+
const dbPath = join(sessionsDir, entry.name);
|
| 61 |
+
try {
|
| 62 |
+
const s = await stat(dbPath);
|
| 63 |
+
if (now - s.mtimeMs > maxAgeMs) {
|
| 64 |
+
await rm(dbPath, { force: true });
|
| 65 |
+
}
|
| 66 |
+
} catch {
|
| 67 |
+
// 单文件失败不阻塞整体 prune
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
}
|
agent/lib/agentfs-sandbox/session.ts
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* InternalSandboxSession 原语 + 公开 SandboxSession 表面。
|
| 3 |
+
*
|
| 4 |
+
* 契约对齐 eve 内部 `buildSandboxSession` / `createFileBackedInternalSandboxSession`:
|
| 5 |
+
* - 后端只实现字节级 read/write、spawn、removePath、resolvePath
|
| 6 |
+
* - 公开表面负责 encoding、行范围、run 包装、二进制/文本变体
|
| 7 |
+
* - 公开层在委托前 resolvePath;内部方法收到的 path 已是解析后的绝对路径
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
import { dirname } from "node:path";
|
| 11 |
+
|
| 12 |
+
import type {
|
| 13 |
+
SandboxProcess,
|
| 14 |
+
SandboxSession,
|
| 15 |
+
SandboxNetworkPolicy,
|
| 16 |
+
} from "eve/sandbox";
|
| 17 |
+
import type { IFileSystem } from "just-bash";
|
| 18 |
+
|
| 19 |
+
import { resolveWorkspacePath } from "../agentfs/paths.js";
|
| 20 |
+
import type { JustBashSandboxInstance } from "./host.js";
|
| 21 |
+
import {
|
| 22 |
+
adaptCommandToSandboxProcess,
|
| 23 |
+
type MultiplexedCommand,
|
| 24 |
+
} from "./process.js";
|
| 25 |
+
|
| 26 |
+
/** 与 AI SDK / eve InternalSandboxSession 对齐的最小原语集。 */
|
| 27 |
+
export interface InternalSandboxSession {
|
| 28 |
+
readonly id: string;
|
| 29 |
+
resolvePath(path: string): string;
|
| 30 |
+
spawn(opts: {
|
| 31 |
+
command: string;
|
| 32 |
+
env?: Record<string, string>;
|
| 33 |
+
abortSignal?: AbortSignal;
|
| 34 |
+
workingDirectory?: string;
|
| 35 |
+
}): Promise<SandboxProcess>;
|
| 36 |
+
readFile(opts: {
|
| 37 |
+
path: string;
|
| 38 |
+
abortSignal?: AbortSignal;
|
| 39 |
+
}): Promise<ReadableStream<Uint8Array> | null>;
|
| 40 |
+
writeFile(opts: {
|
| 41 |
+
path: string;
|
| 42 |
+
content: ReadableStream<Uint8Array>;
|
| 43 |
+
abortSignal?: AbortSignal;
|
| 44 |
+
}): Promise<void>;
|
| 45 |
+
removePath(opts: {
|
| 46 |
+
path: string;
|
| 47 |
+
force?: boolean;
|
| 48 |
+
recursive?: boolean;
|
| 49 |
+
abortSignal?: AbortSignal;
|
| 50 |
+
}): Promise<void>;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
/** shell 单引号转义(与 eve shell-quote 同构)。 */
|
| 54 |
+
function shellQuote(value: string): string {
|
| 55 |
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
function isNotFound(err: unknown): boolean {
|
| 59 |
+
const msg = err instanceof Error ? err.message : String(err);
|
| 60 |
+
return msg.includes("not found") || msg.includes("ENOENT");
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
async function streamToBytes(
|
| 64 |
+
stream: ReadableStream<Uint8Array>,
|
| 65 |
+
): Promise<Uint8Array> {
|
| 66 |
+
const reader = stream.getReader();
|
| 67 |
+
const parts: Uint8Array[] = [];
|
| 68 |
+
let total = 0;
|
| 69 |
+
while (true) {
|
| 70 |
+
const { done, value } = await reader.read();
|
| 71 |
+
if (done) break;
|
| 72 |
+
if (value) {
|
| 73 |
+
parts.push(value);
|
| 74 |
+
total += value.length;
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
const out = new Uint8Array(total);
|
| 78 |
+
let pos = 0;
|
| 79 |
+
for (const part of parts) {
|
| 80 |
+
out.set(part, pos);
|
| 81 |
+
pos += part.length;
|
| 82 |
+
}
|
| 83 |
+
return out;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
function bytesToStream(bytes: Uint8Array): ReadableStream<Uint8Array> {
|
| 87 |
+
return new ReadableStream<Uint8Array>({
|
| 88 |
+
start(controller) {
|
| 89 |
+
controller.enqueue(bytes);
|
| 90 |
+
controller.close();
|
| 91 |
+
},
|
| 92 |
+
});
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
async function readBytesFromFs(
|
| 96 |
+
fs: IFileSystem,
|
| 97 |
+
path: string,
|
| 98 |
+
): Promise<Uint8Array> {
|
| 99 |
+
return await fs.readFileBuffer(path);
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
/**
|
| 103 |
+
* 从 just-bash Sandbox + 已 pad 的 IFileSystem 构造内部 session。
|
| 104 |
+
* id 使用 sessionKey(与官方 just-bash 一致,便于跨重连识别)。
|
| 105 |
+
*/
|
| 106 |
+
export function buildInternalSession(opts: {
|
| 107 |
+
id: string;
|
| 108 |
+
sandbox: JustBashSandboxInstance;
|
| 109 |
+
fs: IFileSystem;
|
| 110 |
+
}): InternalSandboxSession {
|
| 111 |
+
const { id, sandbox, fs } = opts;
|
| 112 |
+
|
| 113 |
+
return {
|
| 114 |
+
id,
|
| 115 |
+
resolvePath: resolveWorkspacePath,
|
| 116 |
+
|
| 117 |
+
async spawn(spawnOpts) {
|
| 118 |
+
if (spawnOpts.abortSignal?.aborted) {
|
| 119 |
+
throw new DOMException("The operation was aborted.", "AbortError");
|
| 120 |
+
}
|
| 121 |
+
// workingDirectory 用子 shell cd 包住(与 eve just-bash-runtime 一致)
|
| 122 |
+
const command =
|
| 123 |
+
spawnOpts.workingDirectory === undefined
|
| 124 |
+
? spawnOpts.command
|
| 125 |
+
: `( cd ${shellQuote(spawnOpts.workingDirectory)} && ${spawnOpts.command} )`;
|
| 126 |
+
|
| 127 |
+
const cmd = await sandbox.runCommand({
|
| 128 |
+
cmd: "eval",
|
| 129 |
+
args: [command],
|
| 130 |
+
detached: true,
|
| 131 |
+
env: spawnOpts.env,
|
| 132 |
+
signal: spawnOpts.abortSignal,
|
| 133 |
+
});
|
| 134 |
+
return adaptCommandToSandboxProcess(cmd as unknown as MultiplexedCommand);
|
| 135 |
+
},
|
| 136 |
+
|
| 137 |
+
async readFile({ path }) {
|
| 138 |
+
// path 已由公开层 resolvePath
|
| 139 |
+
try {
|
| 140 |
+
const bytes = await readBytesFromFs(fs, path);
|
| 141 |
+
return bytesToStream(bytes);
|
| 142 |
+
} catch (err) {
|
| 143 |
+
if (isNotFound(err)) return null;
|
| 144 |
+
throw err;
|
| 145 |
+
}
|
| 146 |
+
},
|
| 147 |
+
|
| 148 |
+
async writeFile({ path, content }) {
|
| 149 |
+
const parent = dirname(path);
|
| 150 |
+
if (parent && parent !== "/") {
|
| 151 |
+
try {
|
| 152 |
+
await fs.mkdir(parent, { recursive: true });
|
| 153 |
+
} catch {
|
| 154 |
+
// parent 已存在 / 是文件时,下一行 writeFile 会给出真实错误
|
| 155 |
+
}
|
| 156 |
+
}
|
| 157 |
+
const bytes = await streamToBytes(content);
|
| 158 |
+
await fs.writeFile(path, bytes);
|
| 159 |
+
},
|
| 160 |
+
|
| 161 |
+
async removePath({ path, force, recursive }) {
|
| 162 |
+
try {
|
| 163 |
+
await fs.rm(path, { force, recursive });
|
| 164 |
+
} catch (err) {
|
| 165 |
+
if (force && isNotFound(err)) return;
|
| 166 |
+
throw err;
|
| 167 |
+
}
|
| 168 |
+
},
|
| 169 |
+
};
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
/** just-bash 不支持运行时改网络策略——与官方后端同语义抛错。 */
|
| 173 |
+
export async function setNetworkPolicyUnsupported(
|
| 174 |
+
_policy: SandboxNetworkPolicy,
|
| 175 |
+
): Promise<void> {
|
| 176 |
+
throw new Error(
|
| 177 |
+
"setNetworkPolicy() is not supported on the agentfs-just-bash backend. " +
|
| 178 |
+
"just-bash applies its network policy only at sandbox creation " +
|
| 179 |
+
"(no run-time update) and does not run git or other binaries.",
|
| 180 |
+
);
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
function validateReadTextFileOptions(opts: {
|
| 184 |
+
startLine?: number;
|
| 185 |
+
endLine?: number;
|
| 186 |
+
}): void {
|
| 187 |
+
const { startLine, endLine } = opts;
|
| 188 |
+
if (startLine !== undefined && (!Number.isInteger(startLine) || startLine < 1)) {
|
| 189 |
+
throw new Error("startLine must be a positive integer (1-based).");
|
| 190 |
+
}
|
| 191 |
+
if (endLine !== undefined && (!Number.isInteger(endLine) || endLine < 1)) {
|
| 192 |
+
throw new Error("endLine must be a positive integer (1-based).");
|
| 193 |
+
}
|
| 194 |
+
if (
|
| 195 |
+
startLine !== undefined &&
|
| 196 |
+
endLine !== undefined &&
|
| 197 |
+
startLine > endLine
|
| 198 |
+
) {
|
| 199 |
+
throw new Error("startLine must not be greater than endLine.");
|
| 200 |
+
}
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
function splitLinesPreservingEndings(text: string): string[] {
|
| 204 |
+
const lines: string[] = [];
|
| 205 |
+
let start = 0;
|
| 206 |
+
for (let i = 0; i < text.length; i++) {
|
| 207 |
+
if (text[i] === "\r") {
|
| 208 |
+
if (i + 1 < text.length && text[i + 1] === "\n") {
|
| 209 |
+
lines.push(text.slice(start, i + 2));
|
| 210 |
+
start = i + 2;
|
| 211 |
+
i++;
|
| 212 |
+
} else {
|
| 213 |
+
lines.push(text.slice(start, i + 1));
|
| 214 |
+
start = i + 1;
|
| 215 |
+
}
|
| 216 |
+
} else if (text[i] === "\n") {
|
| 217 |
+
lines.push(text.slice(start, i + 1));
|
| 218 |
+
start = i + 1;
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
if (start < text.length) lines.push(text.slice(start));
|
| 222 |
+
return lines;
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
function applyLineRange(
|
| 226 |
+
text: string,
|
| 227 |
+
opts: { startLine?: number; endLine?: number },
|
| 228 |
+
): string {
|
| 229 |
+
if (opts.startLine === undefined && opts.endLine === undefined) return text;
|
| 230 |
+
const lines = splitLinesPreservingEndings(text);
|
| 231 |
+
const total = lines.length;
|
| 232 |
+
const from = opts.startLine ?? 1;
|
| 233 |
+
const to = Math.min(opts.endLine ?? total, total);
|
| 234 |
+
if (from > total) return "";
|
| 235 |
+
return lines.slice(from - 1, to).join("");
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
function decodeBytes(bytes: Uint8Array, encoding: string): string {
|
| 239 |
+
if (encoding === "utf-8" || encoding === "utf8") {
|
| 240 |
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
| 241 |
+
}
|
| 242 |
+
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString(
|
| 243 |
+
encoding as BufferEncoding,
|
| 244 |
+
);
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
function encodeString(text: string, encoding: string): Uint8Array {
|
| 248 |
+
if (encoding === "utf-8" || encoding === "utf8") {
|
| 249 |
+
return new TextEncoder().encode(text);
|
| 250 |
+
}
|
| 251 |
+
return new Uint8Array(Buffer.from(text, encoding as BufferEncoding));
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
async function collectStreamToString(
|
| 255 |
+
stream: ReadableStream<Uint8Array>,
|
| 256 |
+
): Promise<string> {
|
| 257 |
+
return new TextDecoder().decode(await streamToBytes(stream));
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
/**
|
| 261 |
+
* 从内部原语构造完整公开 SandboxSession。
|
| 262 |
+
* 行为对齐 eve `buildSandboxSession`(encoding / 行范围 / run 包装)。
|
| 263 |
+
*/
|
| 264 |
+
export function buildPublicSession(
|
| 265 |
+
internal: InternalSandboxSession,
|
| 266 |
+
setNetworkPolicy: (
|
| 267 |
+
policy: SandboxNetworkPolicy,
|
| 268 |
+
) => Promise<void> = setNetworkPolicyUnsupported,
|
| 269 |
+
): SandboxSession {
|
| 270 |
+
return {
|
| 271 |
+
id: internal.id,
|
| 272 |
+
resolvePath: (path) => internal.resolvePath(path),
|
| 273 |
+
|
| 274 |
+
async run(opts) {
|
| 275 |
+
const proc = await internal.spawn({
|
| 276 |
+
command: opts.command,
|
| 277 |
+
env: opts.env,
|
| 278 |
+
abortSignal: opts.abortSignal,
|
| 279 |
+
workingDirectory: opts.workingDirectory,
|
| 280 |
+
});
|
| 281 |
+
const [stdout, stderr, exit] = await Promise.all([
|
| 282 |
+
collectStreamToString(proc.stdout),
|
| 283 |
+
collectStreamToString(proc.stderr),
|
| 284 |
+
proc.wait(),
|
| 285 |
+
]);
|
| 286 |
+
return { exitCode: exit.exitCode, stdout, stderr };
|
| 287 |
+
},
|
| 288 |
+
|
| 289 |
+
async spawn(opts) {
|
| 290 |
+
return await internal.spawn({
|
| 291 |
+
command: opts.command,
|
| 292 |
+
env: opts.env,
|
| 293 |
+
abortSignal: opts.abortSignal,
|
| 294 |
+
workingDirectory: opts.workingDirectory,
|
| 295 |
+
});
|
| 296 |
+
},
|
| 297 |
+
|
| 298 |
+
async readFile(opts) {
|
| 299 |
+
return await internal.readFile({
|
| 300 |
+
abortSignal: opts.abortSignal,
|
| 301 |
+
path: internal.resolvePath(opts.path),
|
| 302 |
+
});
|
| 303 |
+
},
|
| 304 |
+
|
| 305 |
+
async readBinaryFile(opts) {
|
| 306 |
+
const stream = await internal.readFile({
|
| 307 |
+
abortSignal: opts.abortSignal,
|
| 308 |
+
path: internal.resolvePath(opts.path),
|
| 309 |
+
});
|
| 310 |
+
return stream === null ? null : await streamToBytes(stream);
|
| 311 |
+
},
|
| 312 |
+
|
| 313 |
+
async readTextFile(opts) {
|
| 314 |
+
validateReadTextFileOptions(opts);
|
| 315 |
+
const stream = await internal.readFile({
|
| 316 |
+
abortSignal: opts.abortSignal,
|
| 317 |
+
path: internal.resolvePath(opts.path),
|
| 318 |
+
});
|
| 319 |
+
if (stream === null) return null;
|
| 320 |
+
const text = decodeBytes(
|
| 321 |
+
await streamToBytes(stream),
|
| 322 |
+
opts.encoding ?? "utf-8",
|
| 323 |
+
);
|
| 324 |
+
return applyLineRange(text, opts);
|
| 325 |
+
},
|
| 326 |
+
|
| 327 |
+
async writeFile(opts) {
|
| 328 |
+
await internal.writeFile({
|
| 329 |
+
abortSignal: opts.abortSignal,
|
| 330 |
+
content: opts.content,
|
| 331 |
+
path: internal.resolvePath(opts.path),
|
| 332 |
+
});
|
| 333 |
+
},
|
| 334 |
+
|
| 335 |
+
async writeBinaryFile(opts) {
|
| 336 |
+
const bytes =
|
| 337 |
+
opts.content instanceof Uint8Array
|
| 338 |
+
? opts.content
|
| 339 |
+
: new Uint8Array(opts.content);
|
| 340 |
+
await internal.writeFile({
|
| 341 |
+
abortSignal: opts.abortSignal,
|
| 342 |
+
content: bytesToStream(bytes),
|
| 343 |
+
path: internal.resolvePath(opts.path),
|
| 344 |
+
});
|
| 345 |
+
},
|
| 346 |
+
|
| 347 |
+
async writeTextFile(opts) {
|
| 348 |
+
const bytes = encodeString(opts.content, opts.encoding ?? "utf-8");
|
| 349 |
+
await internal.writeFile({
|
| 350 |
+
abortSignal: opts.abortSignal,
|
| 351 |
+
content: bytesToStream(bytes),
|
| 352 |
+
path: internal.resolvePath(opts.path),
|
| 353 |
+
});
|
| 354 |
+
},
|
| 355 |
+
|
| 356 |
+
async removePath(opts) {
|
| 357 |
+
await internal.removePath({
|
| 358 |
+
abortSignal: opts.abortSignal,
|
| 359 |
+
force: opts.force,
|
| 360 |
+
path: internal.resolvePath(opts.path),
|
| 361 |
+
recursive: opts.recursive,
|
| 362 |
+
});
|
| 363 |
+
},
|
| 364 |
+
|
| 365 |
+
setNetworkPolicy,
|
| 366 |
+
};
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
export { streamToBytes, bytesToStream };
|
agent/lib/agentfs/paths.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* AgentFS 沙箱数据布局与路径解析。
|
| 3 |
+
*
|
| 4 |
+
* 布局(`<dataDir>/agentfs/<backendName>/`):
|
| 5 |
+
* templates/<templateKey>.db 模板库(prewarm 捕获)
|
| 6 |
+
* sessions/<sessionKey>.db 每个 eve session 一个独立库
|
| 7 |
+
*
|
| 8 |
+
* `dataDir`:`EVEAGENT_DATA_DIR` 优先,否则 `<appRoot>/data`。
|
| 9 |
+
* sessionKey 已被 eve `sanitizeRuntimeSandboxKey` 限到 `[a-zA-Z0-9._-]{1,120}`,可直接当文件名。
|
| 10 |
+
*/
|
| 11 |
+
|
| 12 |
+
import { join, resolve } from "node:path";
|
| 13 |
+
|
| 14 |
+
/** 与 defineSandbox backend.name 一致,参与 cache-key 与 reconnect 校验。 */
|
| 15 |
+
export const AGENTFS_BACKEND_NAME = "agentfs-just-bash";
|
| 16 |
+
|
| 17 |
+
/** eve sandbox 工作根(所有后端统一命名空间)。 */
|
| 18 |
+
export const WORKSPACE_ROOT = "/workspace";
|
| 19 |
+
|
| 20 |
+
/** 数据目录根:`EVEAGENT_DATA_DIR` 优先,否则 `<appRoot>/data`(缺省 appRoot 用 cwd)。 */
|
| 21 |
+
export function resolveDataDir(appRoot?: string): string {
|
| 22 |
+
const fromEnv = process.env.EVEAGENT_DATA_DIR?.trim();
|
| 23 |
+
if (fromEnv) return resolve(fromEnv);
|
| 24 |
+
return resolve(appRoot ?? process.cwd(), "data");
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
/** `<dataDir>/agentfs/<backendName>` */
|
| 28 |
+
export function resolveBackendRoot(dataDir: string): string {
|
| 29 |
+
return join(dataDir, "agentfs", AGENTFS_BACKEND_NAME);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
/** 模板库目录。 */
|
| 33 |
+
export function resolveTemplatesDir(dataDir: string): string {
|
| 34 |
+
return join(resolveBackendRoot(dataDir), "templates");
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
/** sessions 库目录。 */
|
| 38 |
+
export function resolveSessionsDir(dataDir: string): string {
|
| 39 |
+
return join(resolveBackendRoot(dataDir), "sessions");
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
/** 模板库路径(per-templateKey)。 */
|
| 43 |
+
export function resolveTemplateDbPath(
|
| 44 |
+
dataDir: string,
|
| 45 |
+
templateKey: string,
|
| 46 |
+
): string {
|
| 47 |
+
return join(resolveTemplatesDir(dataDir), `${templateKey}.db`);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/** session 库路径(per-sessionKey)。 */
|
| 51 |
+
export function resolveSessionDbPath(
|
| 52 |
+
dataDir: string,
|
| 53 |
+
sessionKey: string,
|
| 54 |
+
): string {
|
| 55 |
+
return join(resolveSessionsDir(dataDir), `${sessionKey}.db`);
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/** 把 eve sandbox 相对路径锚定到 `/workspace/...`;绝对路径原样透传。 */
|
| 59 |
+
export function resolveWorkspacePath(path: string): string {
|
| 60 |
+
if (!path || path === "/") return WORKSPACE_ROOT;
|
| 61 |
+
return path.startsWith("/") ? path : `${WORKSPACE_ROOT}/${path}`;
|
| 62 |
+
}
|
agent/sandbox.ts
CHANGED
|
@@ -1,681 +1,19 @@
|
|
| 1 |
/**
|
| 2 |
-
* Eve
|
| 3 |
*
|
| 4 |
-
*
|
| 5 |
-
* - name "agentfs-just-bash"(profile 重启识别用)
|
| 6 |
-
* - prewarm 模板级:把 AgentFS 模板库 + just-bash `Sandbox` 缓存到 per-templateKey Map
|
| 7 |
-
* - create session 级:每个 durable session 一个独立 AgentFS 数据库 + 独立 `Sandbox`,
|
| 8 |
-
* 通过 `existingMetadata.dbPath` 跨重启命中同一库
|
| 9 |
*
|
| 10 |
-
* 数据
|
| 11 |
-
*
|
| 12 |
-
* sessions/<sessionKey>.db 每个 eve session 一个独立库
|
| 13 |
-
*
|
| 14 |
-
* 关键事实:
|
| 15 |
-
* - `dataDir` 取自 `EVEAGENT_DATA_DIR`,否则用 `<appRoot>/data`。
|
| 16 |
-
* - sessionKey 由 eve runtime 派生(`runtime/sandbox/keys.js:11-13, 21-22`),
|
| 17 |
-
* 已经被 `sanitizeRuntimeSandboxKey` 限到 `[a-zA-Z0-9._-]{1,120}`,可直接当文件名。
|
| 18 |
-
* - just-bash 没有真后台,`spawn` 通过 `Sandbox.runCommand({ detached: true })`
|
| 19 |
-
* 拿到返回的 `Command`,单次消费 `command.logs()` 再 fan-out 到 stdout/stderr 流,
|
| 20 |
-
* 适配 eve `SandboxProcess` 形状。
|
| 21 |
-
* - `setNetworkPolicy()` just-bash 不支持;按 eve 内部语义抛错,避免误用。
|
| 22 |
-
* - HF Space 容器非持久;务必把 `EVEAGENT_DATA_DIR` 指向持久卷,
|
| 23 |
-
* 否则 per-session 数据库随容器回收即丢。
|
| 24 |
-
*/
|
| 25 |
-
|
| 26 |
-
import { mkdir } from "node:fs/promises";
|
| 27 |
-
import { dirname } from "node:path";
|
| 28 |
-
|
| 29 |
-
import {
|
| 30 |
-
defineSandbox,
|
| 31 |
-
type SandboxBackend,
|
| 32 |
-
type SandboxBackendCreateInput,
|
| 33 |
-
type SandboxBackendHandle,
|
| 34 |
-
type SandboxBackendPrewarmInput,
|
| 35 |
-
type SandboxBackendRuntimeContext,
|
| 36 |
-
type SandboxBackendSessionState,
|
| 37 |
-
type SandboxSession,
|
| 38 |
-
} from "eve/sandbox";
|
| 39 |
-
|
| 40 |
-
import { AgentFS } from "agentfs-sdk";
|
| 41 |
-
import { AgentFsWrapper as JustBashFsWrapper } from "agentfs-sdk/just-bash";
|
| 42 |
-
import {
|
| 43 |
-
Sandbox as JustBashSandbox,
|
| 44 |
-
type IFileSystem,
|
| 45 |
-
} from "just-bash";
|
| 46 |
-
|
| 47 |
-
const BACKEND_NAME = "agentfs-just-bash";
|
| 48 |
-
const WORKSPACE_ROOT = "/workspace";
|
| 49 |
-
|
| 50 |
-
/** 数据目录根。`EVEAGENT_DATA_DIR` 优先,否则退到 `<appRoot>/data`。 */
|
| 51 |
-
function resolveDataDir(appRoot: string): string {
|
| 52 |
-
return process.env.EVEAGENT_DATA_DIR ?? `${appRoot}/data`;
|
| 53 |
-
}
|
| 54 |
-
|
| 55 |
-
/** AgentFS 模板库路径(per-templateKey)。 */
|
| 56 |
-
function resolveTemplateDbPath(dataDir: string, templateKey: string): string {
|
| 57 |
-
return `${dataDir}/agentfs/${BACKEND_NAME}/templates/${templateKey}.db`;
|
| 58 |
-
}
|
| 59 |
-
|
| 60 |
-
/** AgentFS session 库路径(per-sessionKey)。 */
|
| 61 |
-
function resolveSessionDbPath(dataDir: string, sessionKey: string): string {
|
| 62 |
-
return `${dataDir}/agentfs/${BACKEND_NAME}/sessions/${sessionKey}.db`;
|
| 63 |
-
}
|
| 64 |
-
|
| 65 |
-
/** Session 库所在目录。prune 用。 */
|
| 66 |
-
function resolveSessionDbDir(dataDir: string): string {
|
| 67 |
-
return `${dataDir}/agentfs/${BACKEND_NAME}/sessions`;
|
| 68 |
-
}
|
| 69 |
-
|
| 70 |
-
/** 把 eve sandbox 相对路径锚定到 `/workspace/...`。绝对路径原样透传。 */
|
| 71 |
-
function resolveWorkspacePath(p: string): string {
|
| 72 |
-
if (!p || p === "/") return WORKSPACE_ROOT;
|
| 73 |
-
return p.startsWith("/") ? p : `${WORKSPACE_ROOT}/${p}`;
|
| 74 |
-
}
|
| 75 |
-
|
| 76 |
-
/** 进程级单例:同一进程内所有 eve session 共享同一 AgentFS 数据库与 just-bash 解释器。 */
|
| 77 |
-
interface AgentFsHost {
|
| 78 |
-
agentFs: Awaited<ReturnType<typeof AgentFS.open>>;
|
| 79 |
-
// Sandbox 的 constructor 是 private 的,`InstanceType<typeof Sandbox>` 拿不到。
|
| 80 |
-
// host 持有的就是 `JustBashSandbox.create(...)` 的返回值,按 await 后的类型推。
|
| 81 |
-
sandbox: Awaited<ReturnType<typeof JustBashSandbox.create>>;
|
| 82 |
-
fs: IFileSystem;
|
| 83 |
-
dbPath: string;
|
| 84 |
-
}
|
| 85 |
-
|
| 86 |
-
/**
|
| 87 |
-
* just-bash 内部 `$a` 函数对 `IFileSystem` 做统一 wrap:
|
| 88 |
-
* 它**不区分** optional methods,对每个属性(如 `realpath`、`utimes`、`readFileBuffer`、
|
| 89 |
-
* `readdirWithFileTypes`)都 `bind` 调用一遍。
|
| 90 |
-
* `AgentFsWrapper` 缺这些方法 -> `undefined.bind` 抛错 -> `eval` 全部命令失败。
|
| 91 |
-
*
|
| 92 |
-
* 这里包装一层把所有 just-bash 期望的 optional 方法补全成最小行为 stub,
|
| 93 |
-
* 行为差异:
|
| 94 |
-
* - realpath/utimes:不涉及,noop
|
| 95 |
-
* - readFileBuffer / readFileBytes:调 readFile 后转 Uint8Array
|
| 96 |
-
* - readdirWithFileTypes:与 readdir 同结果(不返回 types)
|
| 97 |
-
* - appendFile / writeFileBytes:用 readFile + writeFile 模拟
|
| 98 |
-
* - getAllPaths:返回 [](AgentFS 不支持列出所有路径)
|
| 99 |
-
*/
|
| 100 |
-
function padJustBashFs(inner: IFileSystem): IFileSystem {
|
| 101 |
-
const readFileBuffer = async (path: string): Promise<Uint8Array> => {
|
| 102 |
-
const text = await inner.readFile(path, "utf-8");
|
| 103 |
-
return new TextEncoder().encode(text);
|
| 104 |
-
};
|
| 105 |
-
const writeFileBytes = async (
|
| 106 |
-
path: string,
|
| 107 |
-
bytes: Uint8Array,
|
| 108 |
-
): Promise<void> => {
|
| 109 |
-
await inner.writeFile(path, bytes);
|
| 110 |
-
};
|
| 111 |
-
const appendFile = async (
|
| 112 |
-
path: string,
|
| 113 |
-
content: string | Uint8Array,
|
| 114 |
-
): Promise<void> => {
|
| 115 |
-
const next = content instanceof Uint8Array ? content : new TextEncoder().encode(content);
|
| 116 |
-
let existing: Uint8Array;
|
| 117 |
-
try {
|
| 118 |
-
existing = await readFileBuffer(path);
|
| 119 |
-
} catch {
|
| 120 |
-
existing = new Uint8Array(0);
|
| 121 |
-
}
|
| 122 |
-
const merged = new Uint8Array(existing.length + next.length);
|
| 123 |
-
merged.set(existing, 0);
|
| 124 |
-
merged.set(next, existing.length);
|
| 125 |
-
await inner.writeFile(path, merged);
|
| 126 |
-
};
|
| 127 |
-
const readdirWithFileTypes = async (path: string) => {
|
| 128 |
-
const names = await inner.readdir(path);
|
| 129 |
-
return names.map((name: string) => ({ name }));
|
| 130 |
-
};
|
| 131 |
-
const chmod = async (_path: string, _mode: number): Promise<void> => {
|
| 132 |
-
// noop: AgentFS 不支持 chmod,AgentFsWrapper 内部已经 no-op,这里再垫一层。
|
| 133 |
-
};
|
| 134 |
-
const utimes = async (
|
| 135 |
-
_path: string,
|
| 136 |
-
_atime: number | Date,
|
| 137 |
-
_mtime: number | Date,
|
| 138 |
-
): Promise<void> => {
|
| 139 |
-
// noop: AgentFS 不支持 utimes
|
| 140 |
-
};
|
| 141 |
-
const realpath = async (path: string): Promise<string> => path;
|
| 142 |
-
const getAllPaths = (): string[] => [];
|
| 143 |
-
return Object.assign(Object.create(inner), {
|
| 144 |
-
readFileBuffer,
|
| 145 |
-
writeFileBytes,
|
| 146 |
-
appendFile,
|
| 147 |
-
readdirWithFileTypes,
|
| 148 |
-
chmod,
|
| 149 |
-
utimes,
|
| 150 |
-
realpath,
|
| 151 |
-
getAllPaths,
|
| 152 |
-
});
|
| 153 |
-
}
|
| 154 |
-
|
| 155 |
-
/** 进程级 host 缓存。键是 dbPath 而非 templateKey/sessionKey——同一路径命中复用,
|
| 156 |
-
* 不同路径天然分库。prune 时通过 `sessionHostsByDbPath` 反查活跃引用。 */
|
| 157 |
-
const hostsByDbPath = new Map<string, AgentFsHost>();
|
| 158 |
-
/** 反向索引:sessionKey → dbPath,用于 prune 跳过活跃 session。 */
|
| 159 |
-
const sessionHostsBySessionKey = new Map<string, string>();
|
| 160 |
-
|
| 161 |
-
/** 从 dbPath 拆出 sessionKey。仅识别 `<sessionsDir>/<key>.db` 形状。 */
|
| 162 |
-
function dbPathToSessionKey(sessionsDir: string, dbPath: string): string | null {
|
| 163 |
-
if (!dbPath.startsWith(sessionsDir + "/")) return null;
|
| 164 |
-
const rest = dbPath.slice(sessionsDir.length + 1);
|
| 165 |
-
if (!rest.endsWith(".db")) return null;
|
| 166 |
-
return rest.slice(0, -".db".length);
|
| 167 |
-
}
|
| 168 |
-
|
| 169 |
-
/**
|
| 170 |
-
* 打开(命中复用)一个 session 或 template 的 AgentFS + just-bash 组合。
|
| 171 |
-
*
|
| 172 |
-
* - 命中缓存 → 直接返回(避免重复 SQLite open)
|
| 173 |
-
* - 否则 → mkdir parent → `AgentFS.open` → `padJustBashFs` 包装 → `JustBashSandbox.create`
|
| 174 |
-
* - session 还会记录到 `sessionHostsBySessionKey` 供 prune 用
|
| 175 |
-
*/
|
| 176 |
-
async function getOrOpenHost(opts: {
|
| 177 |
-
dbPath: string;
|
| 178 |
-
isSession: boolean;
|
| 179 |
-
sessionKey?: string;
|
| 180 |
-
}): Promise<AgentFsHost> {
|
| 181 |
-
const { dbPath, isSession, sessionKey } = opts;
|
| 182 |
-
const cached = hostsByDbPath.get(dbPath);
|
| 183 |
-
if (cached) return cached;
|
| 184 |
-
await mkdir(dirname(dbPath), { recursive: true });
|
| 185 |
-
const agentFs = await AgentFS.open({ path: dbPath });
|
| 186 |
-
// 直接构造 `AgentFsWrapper`:`agentfs()` 工厂的运行时类型守卫
|
| 187 |
-
// (`handle.fs?.stat === "function"`)在 Node 24 + pnpm 软链下走错兜底分支,
|
| 188 |
-
// 会触发 `AgentFS.open({ fs: handle })` 二次 open 包成 options 报错。
|
| 189 |
-
// 我们已经有 typed handle,直接 wrap 没有歧义。
|
| 190 |
-
// 再用 `padJustBashFs` 把 just-bash `$a` wrap 时一视同仁调到的
|
| 191 |
-
// optional 方法(realpath/utimes/readFileBuffer/…)补成 stub,
|
| 192 |
-
// 否则 `eval` 解析时会 `Cannot read properties of undefined (reading 'bind')`。
|
| 193 |
-
// AgentFsWrapper implements IFileSystem 但缺 realpath/utimes 这两个 optional 方法,
|
| 194 |
-
// padJustBashFs 会补;类型层 cast 到 IFileSystem 让 TS 不挑刺。
|
| 195 |
-
const wrapped = padJustBashFs(
|
| 196 |
-
new JustBashFsWrapper({ fs: agentFs }) as unknown as IFileSystem,
|
| 197 |
-
);
|
| 198 |
-
// just-bash 默认无网络、无 curl;websearch 子 agent(anysearch / firecrawl)需要 POST。
|
| 199 |
-
// 仅放行检索相关 API,不用全网开放。
|
| 200 |
-
const sandboxEnv: Record<string, string> = {};
|
| 201 |
-
for (const key of ["ANYSEARCH_API_KEY", "FIRECRAWL_API_KEY"] as const) {
|
| 202 |
-
const v = process.env[key]?.trim();
|
| 203 |
-
if (v) sandboxEnv[key] = v;
|
| 204 |
-
}
|
| 205 |
-
const sandbox = await JustBashSandbox.create({
|
| 206 |
-
cwd: WORKSPACE_ROOT,
|
| 207 |
-
env: sandboxEnv,
|
| 208 |
-
fs: wrapped,
|
| 209 |
-
network: {
|
| 210 |
-
allowedUrlPrefixes: [
|
| 211 |
-
"https://api.anysearch.com/",
|
| 212 |
-
"https://api.firecrawl.dev/",
|
| 213 |
-
],
|
| 214 |
-
allowedMethods: ["GET", "HEAD", "POST"],
|
| 215 |
-
},
|
| 216 |
-
});
|
| 217 |
-
// /workspace 是 eve sandbox 的工作根;Sandbox.create 不会自动建该目录
|
| 218 |
-
try {
|
| 219 |
-
await sandbox.mkDir(WORKSPACE_ROOT, { recursive: true });
|
| 220 |
-
} catch {
|
| 221 |
-
// 已存在 / 不支持递归都可忽略——写文件时 ifs 会按需 mkdir
|
| 222 |
-
}
|
| 223 |
-
const host: AgentFsHost = { agentFs, sandbox, fs: wrapped, dbPath };
|
| 224 |
-
hostsByDbPath.set(dbPath, host);
|
| 225 |
-
if (isSession && sessionKey) {
|
| 226 |
-
sessionHostsBySessionKey.set(sessionKey, dbPath);
|
| 227 |
-
}
|
| 228 |
-
return host;
|
| 229 |
-
}
|
| 230 |
-
|
| 231 |
-
/** 把 `ReadableStream<Uint8Array>` 全部读完得到合并后的字节。 */
|
| 232 |
-
async function streamToBytes(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
|
| 233 |
-
const reader = stream.getReader();
|
| 234 |
-
const parts: Uint8Array[] = [];
|
| 235 |
-
let total = 0;
|
| 236 |
-
while (true) {
|
| 237 |
-
const { done, value } = await reader.read();
|
| 238 |
-
if (done) break;
|
| 239 |
-
if (value) {
|
| 240 |
-
parts.push(value);
|
| 241 |
-
total += value.length;
|
| 242 |
-
}
|
| 243 |
-
}
|
| 244 |
-
const out = new Uint8Array(total);
|
| 245 |
-
let pos = 0;
|
| 246 |
-
for (const part of parts) {
|
| 247 |
-
out.set(part, pos);
|
| 248 |
-
pos += part.length;
|
| 249 |
-
}
|
| 250 |
-
return out;
|
| 251 |
-
}
|
| 252 |
-
|
| 253 |
-
/**
|
| 254 |
-
* 把 just-bash `Command`(有 `logs()` 异步迭代 + `wait()` + `kill()`)
|
| 255 |
-
* 适配为 eve `SandboxProcess`(stdout/stderr 是 `ReadableStream<Uint8Array>`)。
|
| 256 |
-
*
|
| 257 |
-
* just-bash 的 `logs()` 在命令结束后才 yield(先 await 完整结果),
|
| 258 |
-
* 因此这里**只消费一次** logs,再按 type 拆到两条流,避免双消费者竞态。
|
| 259 |
-
*/
|
| 260 |
-
interface JustBashCommandLike {
|
| 261 |
-
logs(): AsyncIterable<{ type: "stdout" | "stderr"; data: string }>;
|
| 262 |
-
wait(): Promise<{ exitCode: number }>;
|
| 263 |
-
kill(): Promise<void> | void;
|
| 264 |
-
}
|
| 265 |
-
|
| 266 |
-
function adaptCommandToSandboxProcess(command: JustBashCommandLike) {
|
| 267 |
-
const encoder = new TextEncoder();
|
| 268 |
-
|
| 269 |
-
// 共享一次 collect:stdout/stderr 流都从同一结果分发
|
| 270 |
-
let collected: Promise<{
|
| 271 |
-
stdout: Uint8Array[];
|
| 272 |
-
stderr: Uint8Array[];
|
| 273 |
-
}> | null = null;
|
| 274 |
-
|
| 275 |
-
function collectLogs() {
|
| 276 |
-
if (!collected) {
|
| 277 |
-
collected = (async () => {
|
| 278 |
-
const stdout: Uint8Array[] = [];
|
| 279 |
-
const stderr: Uint8Array[] = [];
|
| 280 |
-
for await (const msg of command.logs()) {
|
| 281 |
-
const chunk = encoder.encode(msg.data);
|
| 282 |
-
if (msg.type === "stdout") stdout.push(chunk);
|
| 283 |
-
else stderr.push(chunk);
|
| 284 |
-
}
|
| 285 |
-
return { stdout, stderr };
|
| 286 |
-
})();
|
| 287 |
-
}
|
| 288 |
-
return collected;
|
| 289 |
-
}
|
| 290 |
-
|
| 291 |
-
function streamOf(kind: "stdout" | "stderr") {
|
| 292 |
-
return new ReadableStream<Uint8Array>({
|
| 293 |
-
async start(controller) {
|
| 294 |
-
try {
|
| 295 |
-
const parts = (await collectLogs())[kind];
|
| 296 |
-
for (const part of parts) controller.enqueue(part);
|
| 297 |
-
controller.close();
|
| 298 |
-
} catch (err) {
|
| 299 |
-
controller.error(err);
|
| 300 |
-
}
|
| 301 |
-
},
|
| 302 |
-
});
|
| 303 |
-
}
|
| 304 |
-
|
| 305 |
-
return {
|
| 306 |
-
stdout: streamOf("stdout"),
|
| 307 |
-
stderr: streamOf("stderr"),
|
| 308 |
-
async wait() {
|
| 309 |
-
return await command.wait();
|
| 310 |
-
},
|
| 311 |
-
async kill() {
|
| 312 |
-
await Promise.resolve(command.kill());
|
| 313 |
-
},
|
| 314 |
-
};
|
| 315 |
-
}
|
| 316 |
-
|
| 317 |
-
/** InternalSandboxSession 的最小字段集合——只暴露 eve 真正下传的方法。 */
|
| 318 |
-
interface InternalSandboxSession {
|
| 319 |
-
id: string;
|
| 320 |
-
resolvePath(p: string): string;
|
| 321 |
-
spawn(opts: {
|
| 322 |
-
command: string;
|
| 323 |
-
env?: Record<string, string>;
|
| 324 |
-
abortSignal?: AbortSignal;
|
| 325 |
-
}): Promise<ReturnType<typeof adaptCommandToSandboxProcess>>;
|
| 326 |
-
readFile(opts: {
|
| 327 |
-
path: string;
|
| 328 |
-
abortSignal?: AbortSignal;
|
| 329 |
-
}): Promise<ReadableStream<Uint8Array> | null>;
|
| 330 |
-
writeFile(opts: {
|
| 331 |
-
path: string;
|
| 332 |
-
content: ReadableStream<Uint8Array>;
|
| 333 |
-
abortSignal?: AbortSignal;
|
| 334 |
-
}): Promise<void>;
|
| 335 |
-
removePath(opts: {
|
| 336 |
-
path: string;
|
| 337 |
-
force?: boolean;
|
| 338 |
-
recursive?: boolean;
|
| 339 |
-
abortSignal?: AbortSignal;
|
| 340 |
-
}): Promise<void>;
|
| 341 |
-
}
|
| 342 |
-
|
| 343 |
-
function buildInternalSession(
|
| 344 |
-
sandbox: Awaited<ReturnType<typeof JustBashSandbox.create>>,
|
| 345 |
-
fs: IFileSystem,
|
| 346 |
-
): InternalSandboxSession {
|
| 347 |
-
return {
|
| 348 |
-
id: "agentfs-just-bash",
|
| 349 |
-
resolvePath: resolveWorkspacePath,
|
| 350 |
-
async spawn(opts) {
|
| 351 |
-
if (opts.abortSignal?.aborted) {
|
| 352 |
-
throw new DOMException("The operation was aborted.", "AbortError");
|
| 353 |
-
}
|
| 354 |
-
const command = await sandbox.runCommand({
|
| 355 |
-
cmd: "eval",
|
| 356 |
-
args: [opts.command],
|
| 357 |
-
detached: true,
|
| 358 |
-
env: opts.env,
|
| 359 |
-
signal: opts.abortSignal,
|
| 360 |
-
});
|
| 361 |
-
return adaptCommandToSandboxProcess(command as unknown as JustBashCommandLike);
|
| 362 |
-
},
|
| 363 |
-
async readFile({ path }) {
|
| 364 |
-
const norm = resolveWorkspacePath(path);
|
| 365 |
-
try {
|
| 366 |
-
const bytes = await readBytesFromFs(fs, norm);
|
| 367 |
-
return new ReadableStream<Uint8Array>({
|
| 368 |
-
start(c) {
|
| 369 |
-
c.enqueue(bytes);
|
| 370 |
-
c.close();
|
| 371 |
-
},
|
| 372 |
-
});
|
| 373 |
-
} catch (err) {
|
| 374 |
-
if (isNotFound(err)) return null;
|
| 375 |
-
throw err;
|
| 376 |
-
}
|
| 377 |
-
},
|
| 378 |
-
async writeFile({ path, content }) {
|
| 379 |
-
const norm = resolveWorkspacePath(path);
|
| 380 |
-
const parent = dirname(norm);
|
| 381 |
-
if (parent && parent !== "/") {
|
| 382 |
-
try {
|
| 383 |
-
await fs.mkdir(parent, { recursive: true });
|
| 384 |
-
} catch {
|
| 385 |
-
// parent 可能是文件 / 已存在 / 平台差异——下一行写文件失败时再报错
|
| 386 |
-
}
|
| 387 |
-
}
|
| 388 |
-
const bytes = await streamToBytes(content);
|
| 389 |
-
await fs.writeFile(norm, bytes);
|
| 390 |
-
},
|
| 391 |
-
async removePath({ path, force, recursive }) {
|
| 392 |
-
const norm = resolveWorkspacePath(path);
|
| 393 |
-
try {
|
| 394 |
-
await fs.rm(norm, { force, recursive });
|
| 395 |
-
} catch (err) {
|
| 396 |
-
if (force && isNotFound(err)) return;
|
| 397 |
-
throw err;
|
| 398 |
-
}
|
| 399 |
-
},
|
| 400 |
-
};
|
| 401 |
-
}
|
| 402 |
-
|
| 403 |
-
/** 优先 `readFileBuffer`(已是 Uint8Array),否则按 utf-8 解码 `readFile`。 */
|
| 404 |
-
async function readBytesFromFs(
|
| 405 |
-
fs: IFileSystem,
|
| 406 |
-
norm: string,
|
| 407 |
-
): Promise<Uint8Array> {
|
| 408 |
-
const maybe = (fs as unknown as {
|
| 409 |
-
readFileBuffer?: (path: string) => Promise<Uint8Array>;
|
| 410 |
-
}).readFileBuffer;
|
| 411 |
-
if (typeof maybe === "function") {
|
| 412 |
-
return await maybe.call(fs, norm);
|
| 413 |
-
}
|
| 414 |
-
const text = await fs.readFile(norm, "utf-8");
|
| 415 |
-
return new TextEncoder().encode(text);
|
| 416 |
-
}
|
| 417 |
-
|
| 418 |
-
function isNotFound(err: unknown): boolean {
|
| 419 |
-
const msg = err instanceof Error ? err.message : String(err);
|
| 420 |
-
return msg.includes("not found") || msg.includes("ENOENT");
|
| 421 |
-
}
|
| 422 |
-
|
| 423 |
-
/** just-bash 后端的 network policy 不可中途修改——按 eve 内部语义抛错。 */
|
| 424 |
-
async function setNetworkPolicyUnsupported(): Promise<void> {
|
| 425 |
-
throw new Error(
|
| 426 |
-
"setNetworkPolicy() is not supported on the agentfs-just-bash backend. " +
|
| 427 |
-
"just-bash applies its network policy only at sandbox creation " +
|
| 428 |
-
"(no run-time update) and does not run git or other binaries.",
|
| 429 |
-
);
|
| 430 |
-
}
|
| 431 |
-
|
| 432 |
-
/** 仿 eve 内部 `buildSandboxSession`:把内部 session 包装成公开 surface。 */
|
| 433 |
-
function buildPublicSession(internal: InternalSandboxSession): SandboxSession {
|
| 434 |
-
async function streamToString(stream: ReadableStream<Uint8Array>): Promise<string> {
|
| 435 |
-
return new TextDecoder().decode(await streamToBytes(stream));
|
| 436 |
-
}
|
| 437 |
-
|
| 438 |
-
return {
|
| 439 |
-
id: internal.id,
|
| 440 |
-
resolvePath: internal.resolvePath,
|
| 441 |
-
|
| 442 |
-
async run(opts) {
|
| 443 |
-
const proc = await internal.spawn({
|
| 444 |
-
command: opts.command,
|
| 445 |
-
env: opts.env,
|
| 446 |
-
abortSignal: opts.abortSignal,
|
| 447 |
-
});
|
| 448 |
-
const [stdout, stderr, exit] = await Promise.all([
|
| 449 |
-
streamToString(proc.stdout),
|
| 450 |
-
streamToString(proc.stderr),
|
| 451 |
-
proc.wait(),
|
| 452 |
-
]);
|
| 453 |
-
return { exitCode: exit.exitCode, stdout, stderr };
|
| 454 |
-
},
|
| 455 |
-
|
| 456 |
-
spawn(opts) {
|
| 457 |
-
return internal.spawn({
|
| 458 |
-
command: opts.command,
|
| 459 |
-
env: opts.env,
|
| 460 |
-
abortSignal: opts.abortSignal,
|
| 461 |
-
});
|
| 462 |
-
},
|
| 463 |
-
|
| 464 |
-
async readFile(opts) {
|
| 465 |
-
return internal.readFile({ path: opts.path, abortSignal: opts.abortSignal });
|
| 466 |
-
},
|
| 467 |
-
async readBinaryFile(opts) {
|
| 468 |
-
const s = await internal.readFile({ path: opts.path, abortSignal: opts.abortSignal });
|
| 469 |
-
return s ? await streamToBytes(s) : null;
|
| 470 |
-
},
|
| 471 |
-
async readTextFile(opts) {
|
| 472 |
-
const s = await internal.readFile({ path: opts.path, abortSignal: opts.abortSignal });
|
| 473 |
-
if (!s) return null;
|
| 474 |
-
return new TextDecoder().decode(await streamToBytes(s));
|
| 475 |
-
},
|
| 476 |
-
|
| 477 |
-
async writeFile(opts) {
|
| 478 |
-
await internal.writeFile({
|
| 479 |
-
path: opts.path,
|
| 480 |
-
content: opts.content,
|
| 481 |
-
abortSignal: opts.abortSignal,
|
| 482 |
-
});
|
| 483 |
-
},
|
| 484 |
-
async writeBinaryFile(opts) {
|
| 485 |
-
const bytes = opts.content instanceof Uint8Array ? opts.content : new Uint8Array(opts.content);
|
| 486 |
-
await internal.writeFile({
|
| 487 |
-
path: opts.path,
|
| 488 |
-
content: new ReadableStream<Uint8Array>({
|
| 489 |
-
start(c) {
|
| 490 |
-
c.enqueue(bytes);
|
| 491 |
-
c.close();
|
| 492 |
-
},
|
| 493 |
-
}),
|
| 494 |
-
abortSignal: opts.abortSignal,
|
| 495 |
-
});
|
| 496 |
-
},
|
| 497 |
-
async writeTextFile(opts) {
|
| 498 |
-
const bytes = new TextEncoder().encode(opts.content);
|
| 499 |
-
await internal.writeFile({
|
| 500 |
-
path: opts.path,
|
| 501 |
-
content: new ReadableStream<Uint8Array>({
|
| 502 |
-
start(c) {
|
| 503 |
-
c.enqueue(bytes);
|
| 504 |
-
c.close();
|
| 505 |
-
},
|
| 506 |
-
}),
|
| 507 |
-
abortSignal: opts.abortSignal,
|
| 508 |
-
});
|
| 509 |
-
},
|
| 510 |
-
|
| 511 |
-
async removePath(opts) {
|
| 512 |
-
await internal.removePath({
|
| 513 |
-
path: opts.path,
|
| 514 |
-
force: opts.force,
|
| 515 |
-
recursive: opts.recursive,
|
| 516 |
-
abortSignal: opts.abortSignal,
|
| 517 |
-
});
|
| 518 |
-
},
|
| 519 |
-
|
| 520 |
-
setNetworkPolicy: setNetworkPolicyUnsupported,
|
| 521 |
-
};
|
| 522 |
-
}
|
| 523 |
-
|
| 524 |
-
/**
|
| 525 |
-
* 把 `existingMetadata` 严格解出 dbPath。版本不匹配或字段缺失一律丢弃(记 warn)。
|
| 526 |
-
* 这是 sandbox-backend metadata schema v1 的契约校验点。
|
| 527 |
*/
|
| 528 |
-
function resolveExistingDbPath(
|
| 529 |
-
input: SandboxBackendCreateInput,
|
| 530 |
-
): string | null {
|
| 531 |
-
const m = input.existingMetadata as Record<string, unknown> | undefined;
|
| 532 |
-
if (!m) return null;
|
| 533 |
-
if (m.version !== 1) {
|
| 534 |
-
if (typeof m.dbPath === "string") {
|
| 535 |
-
console.warn(
|
| 536 |
-
`[${BACKEND_NAME}] dropping legacy metadata dbPath=${m.dbPath}; ` +
|
| 537 |
-
`rebuilding session ${input.sessionKey} from scratch`,
|
| 538 |
-
);
|
| 539 |
-
}
|
| 540 |
-
return null;
|
| 541 |
-
}
|
| 542 |
-
if (typeof m.dbPath !== "string") {
|
| 543 |
-
return null;
|
| 544 |
-
}
|
| 545 |
-
return m.dbPath;
|
| 546 |
-
}
|
| 547 |
-
|
| 548 |
-
let prunePromise: Promise<void> | null = null;
|
| 549 |
-
/** 第一次 open session host 时 lazy 触发一次 prune,避免硬编码到 Nitro 启动 hook。 */
|
| 550 |
-
function maybePruneStaleSessions(appRoot: string): Promise<void> {
|
| 551 |
-
if (prunePromise) return prunePromise;
|
| 552 |
-
prunePromise = pruneStaleSessions(appRoot).catch((err) => {
|
| 553 |
-
// prune 失败不应阻塞 session 创建;仅记录
|
| 554 |
-
console.warn(`[${BACKEND_NAME}] stale-session prune failed:`, err);
|
| 555 |
-
});
|
| 556 |
-
return prunePromise;
|
| 557 |
-
}
|
| 558 |
-
|
| 559 |
-
/**
|
| 560 |
-
* 清理 sessions 目录下 mtime 超过 `AGENTFS_SESSION_MAX_AGE_DAYS` 天的 `.db` 文件。
|
| 561 |
-
* - 跳过 `sessionHostsBySessionKey` 仍在引用的活跃 session
|
| 562 |
-
* - 不动 `templates/`(template 是 build plan 派生,删错要重 build)
|
| 563 |
-
*/
|
| 564 |
-
async function pruneStaleSessions(appRoot: string): Promise<void> {
|
| 565 |
-
const { readdir, stat, rm } = await import("node:fs/promises");
|
| 566 |
-
const dataDir = resolveDataDir(appRoot);
|
| 567 |
-
const sessionsDir = resolveSessionDbDir(dataDir);
|
| 568 |
-
let entries: import("node:fs").Dirent[];
|
| 569 |
-
try {
|
| 570 |
-
entries = await readdir(sessionsDir, { withFileTypes: true });
|
| 571 |
-
} catch {
|
| 572 |
-
// 目录不存在(首次启动)—无需清理
|
| 573 |
-
return;
|
| 574 |
-
}
|
| 575 |
-
const maxAgeDays = Number(
|
| 576 |
-
process.env.AGENTFS_SESSION_MAX_AGE_DAYS ?? 30,
|
| 577 |
-
);
|
| 578 |
-
if (!Number.isFinite(maxAgeDays) || maxAgeDays <= 0) return;
|
| 579 |
-
const maxAgeMs = maxAgeDays * 86_400_000;
|
| 580 |
-
const now = Date.now();
|
| 581 |
-
for (const e of entries) {
|
| 582 |
-
if (!e.isFile() || !e.name.endsWith(".db")) continue;
|
| 583 |
-
const dbPath = `${sessionsDir}/${e.name}`;
|
| 584 |
-
const sessionKey = dbPathToSessionKey(sessionsDir, dbPath);
|
| 585 |
-
if (sessionKey && sessionHostsBySessionKey.has(sessionKey)) continue;
|
| 586 |
-
try {
|
| 587 |
-
const s = await stat(dbPath);
|
| 588 |
-
if (now - s.mtimeMs > maxAgeMs) {
|
| 589 |
-
await rm(dbPath, { force: true });
|
| 590 |
-
}
|
| 591 |
-
} catch {
|
| 592 |
-
// 单文件失败不阻塞整体 prune
|
| 593 |
-
}
|
| 594 |
-
}
|
| 595 |
-
}
|
| 596 |
-
|
| 597 |
-
function agentfsJustBashBackend(): SandboxBackend {
|
| 598 |
-
return {
|
| 599 |
-
name: BACKEND_NAME,
|
| 600 |
-
|
| 601 |
-
async prewarm({
|
| 602 |
-
runtimeContext,
|
| 603 |
-
templateKey,
|
| 604 |
-
}: SandboxBackendPrewarmInput): Promise<{ reused: boolean }> {
|
| 605 |
-
// 当前 evework 没有 bootstrap / agent/sandbox/workspace/ —— templateKey 始终为 null,
|
| 606 |
-
// eve runtime 不会调到这个分支(prewarm.js:20 的 `c !== null` 守卫)。
|
| 607 |
-
// 这里仍然写对以备将来加 bootstrap 时使用。
|
| 608 |
-
if (templateKey === null) return { reused: false };
|
| 609 |
-
const dataDir = resolveDataDir(runtimeContext.appRoot);
|
| 610 |
-
const dbPath = resolveTemplateDbPath(dataDir, templateKey);
|
| 611 |
-
const cached = hostsByDbPath.get(dbPath);
|
| 612 |
-
if (cached) {
|
| 613 |
-
// touch:刷新 mtime,让外部的「最近活跃模板」统计拿到信号
|
| 614 |
-
try {
|
| 615 |
-
const now = new Date();
|
| 616 |
-
await cached.fs.utimes(dbPath, now, now);
|
| 617 |
-
} catch {
|
| 618 |
-
// utimes 是 stub(AgentFsWrapper 不支持)—— 失败无害
|
| 619 |
-
}
|
| 620 |
-
return { reused: true };
|
| 621 |
-
}
|
| 622 |
-
await getOrOpenHost({ dbPath, isSession: false });
|
| 623 |
-
// TODO: bootstrap / seedFiles 进入时,把 host close,然后
|
| 624 |
-
// create 阶段用 SQLite `VACUUM INTO` 拿原子快照派生 session。
|
| 625 |
-
return { reused: false };
|
| 626 |
-
},
|
| 627 |
-
|
| 628 |
-
async create(
|
| 629 |
-
input: SandboxBackendCreateInput,
|
| 630 |
-
): Promise<SandboxBackendHandle> {
|
| 631 |
-
const { runtimeContext, sessionKey, templateKey } = input;
|
| 632 |
-
const dataDir = resolveDataDir(runtimeContext.appRoot);
|
| 633 |
|
| 634 |
-
|
| 635 |
-
const reusedDbPath = sessionHostsBySessionKey.get(sessionKey);
|
| 636 |
-
// 优先级 2:existingMetadata.dbPath(持久化重连)
|
| 637 |
-
const existingDbPath = reusedDbPath ?? resolveExistingDbPath(input);
|
| 638 |
-
// 优先级 3:模板派生(templateKey 非空时,将来用 VACUUM INTO)—— 当前永远 null
|
| 639 |
-
const templateDbPath =
|
| 640 |
-
existingDbPath ?? (templateKey !== null
|
| 641 |
-
? resolveTemplateDbPath(dataDir, templateKey)
|
| 642 |
-
: null);
|
| 643 |
-
// 优先级 4:空建 session 库
|
| 644 |
-
const dbPath =
|
| 645 |
-
templateDbPath ?? resolveSessionDbPath(dataDir, sessionKey);
|
| 646 |
|
| 647 |
-
|
| 648 |
-
const h = await getOrOpenHost({
|
| 649 |
-
dbPath,
|
| 650 |
-
isSession: true,
|
| 651 |
-
sessionKey,
|
| 652 |
-
});
|
| 653 |
-
const internal = buildInternalSession(h.sandbox, h.fs);
|
| 654 |
-
const session = buildPublicSession(internal);
|
| 655 |
-
return {
|
| 656 |
-
session,
|
| 657 |
-
useSessionFn: async () => session,
|
| 658 |
-
async captureState(): Promise<SandboxBackendSessionState> {
|
| 659 |
-
return {
|
| 660 |
-
backendName: BACKEND_NAME,
|
| 661 |
-
sessionKey,
|
| 662 |
-
// metadata schema v1:version + dbPath。
|
| 663 |
-
// 变更前格式 `{ dbPath }`(无 version)会被 resolveExistingDbPath 当作 legacy 丢弃。
|
| 664 |
-
metadata: { version: 1, dbPath: h.dbPath },
|
| 665 |
-
};
|
| 666 |
-
},
|
| 667 |
-
async shutdown(): Promise<void> {
|
| 668 |
-
// 不 evict:进程级 Map 让活跃 session 共享 SQLite 连接直到 SIGTERM 回收。
|
| 669 |
-
// 框架自带 justbash 后端同样 no-op(execution/sandbox/active-handles.js)。
|
| 670 |
-
},
|
| 671 |
-
};
|
| 672 |
-
},
|
| 673 |
-
};
|
| 674 |
-
}
|
| 675 |
|
| 676 |
-
|
| 677 |
-
export { agentfsJustBashBackend };
|
| 678 |
-
export const backendName = BACKEND_NAME;
|
| 679 |
-
export type { SandboxBackendRuntimeContext };
|
| 680 |
|
| 681 |
-
export default defineSandbox({
|
|
|
|
|
|
|
|
|
| 1 |
/**
|
| 2 |
+
* Eve sandbox 定义:AgentFS 持久化 + just-bash 解释器。
|
| 3 |
*
|
| 4 |
+
* 实现细节在 `lib/agentfs-sandbox/`,本文件只做 defineSandbox 入口。
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
*
|
| 6 |
+
* 数据布局见 `lib/agentfs/paths.ts`。
|
| 7 |
+
* HF Space 容器非持久;务必把 `EVEAGENT_DATA_DIR` 指向持久卷。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
*/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
+
import { defineSandbox } from "eve/sandbox";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
+
import { createAgentFsJustBashBackend } from "./lib/agentfs-sandbox/backend.js";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
export { AGENTFS_BACKEND_NAME as backendName } from "./lib/agentfs-sandbox/backend.js";
|
| 15 |
+
export { createAgentFsJustBashBackend as agentfsJustBashBackend } from "./lib/agentfs-sandbox/backend.js";
|
|
|
|
|
|
|
| 16 |
|
| 17 |
+
export default defineSandbox({
|
| 18 |
+
backend: createAgentFsJustBashBackend(),
|
| 19 |
+
});
|
docker-entrypoint.mjs
CHANGED
|
@@ -9,12 +9,18 @@ function log(msg) {
|
|
| 9 |
process.stdout.write(`[evework-boot] ${msg}\n`);
|
| 10 |
}
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
log(`pid=${process.pid} node=${process.version} cwd=${process.cwd()}`);
|
| 13 |
log(
|
| 14 |
`env PORT=${process.env.PORT ?? "(unset)"} HOST=${process.env.HOST ?? "(unset)"} NODE_ENV=${process.env.NODE_ENV ?? "(unset)"}`,
|
| 15 |
);
|
| 16 |
log(
|
| 17 |
-
`
|
| 18 |
);
|
| 19 |
|
| 20 |
const serverEntry = new URL("./.output/server/index.mjs", import.meta.url);
|
|
|
|
| 9 |
process.stdout.write(`[evework-boot] ${msg}\n`);
|
| 10 |
}
|
| 11 |
|
| 12 |
+
const workflowWorld = (process.env.WORKFLOW_WORLD ?? "local").trim().toLowerCase();
|
| 13 |
+
const workflowBoot =
|
| 14 |
+
workflowWorld === "postgres" || workflowWorld === "world-postgres"
|
| 15 |
+
? `workflow=postgres url=${process.env.WORKFLOW_POSTGRES_URL ? "set" : "MISSING"}`
|
| 16 |
+
: `workflow=local data=.workflow-data`;
|
| 17 |
+
|
| 18 |
log(`pid=${process.pid} node=${process.version} cwd=${process.cwd()}`);
|
| 19 |
log(
|
| 20 |
`env PORT=${process.env.PORT ?? "(unset)"} HOST=${process.env.HOST ?? "(unset)"} NODE_ENV=${process.env.NODE_ENV ?? "(unset)"}`,
|
| 21 |
);
|
| 22 |
log(
|
| 23 |
+
`${workflowBoot} custom_base=${process.env.CUSTOM_BASE_URL ? "set" : "MISSING"} custom_model=${process.env.CUSTOM_MODEL ? "set" : "MISSING"} agent_key=${process.env.AGENT_API_KEY ? "set" : "MISSING"}`,
|
| 24 |
);
|
| 25 |
|
| 26 |
const serverEntry = new URL("./.output/server/index.mjs", import.meta.url);
|