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 CHANGED
@@ -1,19 +1,22 @@
1
- # Workflow world(@workflow/world-postgres → Supabase Postgres)
 
 
 
 
 
2
  # Session pooler :5432 或 Direct :5432;勿用 Transaction pooler :6543
3
  #
4
- # 重要: Windows 与 HF Linux 若共用同一 WORKFLOW_POSTGRES_URL,
5
  # 启动时会 Re-enqueue 对方留下的 active run;step 里若带本机绝对路径
6
  # (如 H:\...),在另一台机器上会 FatalError(ERR_INVALID_MODULE_SPECIFIER)。
7
- # 做法二选一
8
- # 1) 不同环境用不同库 / 不同 project
9
- # 2) 至少设不同 JOB_PREFIX,并在切环境前取消残留 active run(见 README)
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 状态 | `@workflow/world-postgres` → Supabase Postgres(`WORKFLOW_POSTGRES_URL`) |
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`。浏览为只读;checkpoint 路由会写磁盘上的存档库并可能改写 session 库内容。鉴权与 `/eve` 相同(Bearer `AGENT_API_KEY` / 本机 `localDev`)。
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
- | `WORKFLOW_POSTGRES_URL` | 是 | Supabase Postgres;Session/Direct `:5432`,**勿用** Transaction `:6543` |
95
- | `WORKFLOW_POSTGRES_JOB_PREFIX` | 建议 | 与本地隔离,如 `hf_`(本地用 `local_`)**只改 graphile 任务名,表仍共享** |
96
- | `WORKFLOW_POSTGRES_WORKER_CONCURRENCY` | 否 | 镜像默认 `3`(包默认 50 会瞬间打满 Session pool) |
97
- | `WORKFLOW_POSTGRES_MAX_POOL_SIZE` | 否 | 镜像默认 `5`; concurrency,且 **远小于** Supabase Session `pool_size`(常见 15) |
 
 
 
 
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. **不要** 本机 `pnpm dev` 与 HF **同时** 大池子连同一 URL。
112
  4. 若 Secrets 里曾设 `MAX_POOL_SIZE=12` 等,删掉或改成 ≤5,否则会覆盖镜像默认。
113
  5. 仍���够:升 Supabase 算力 / 提高 pool,或本机与云端分库。
114
 
115
- 首次部署前在能连库的环境执行 `pnpm workflow:db:setup`。
116
 
117
- **不要把 Windows 的 active run 留给 HF 接着跑。** 两边共用同一 `WORKFLOW_POSTGRES_URL`,启动日志会出现 `Re-enqueued N active run(s)`;本机路径(`H:\...`)在 Linux 容器里会变成类似:
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、WORKFLOW_POSTGRES_URL
 
183
  # 建议:AGENT_API_KEY(公网必填;本机回环可暂空)
 
184
 
185
- # 3) 初始化 workflow 表(首次 / 升级 world-postgres 后)
186
- pnpm workflow:db:setup
187
 
188
  # 4) 跑 eve dev(Nitro + TUI,默认 :2000)
189
  pnpm dev
190
  ```
191
 
192
- 会话 / run / event 存在 Postgres(`workflow_*` + `graphile_worker`),不再依赖 `.workflow-data/`。旧 local 文件状态不会自动迁移。
 
 
 
193
 
194
- 若与 HF 共用同一 Supabase:**务必**设不同的 `WORKFLOW_POSTGRES_JOB_PREFIX`,且不要留下跨机器的 active run(见上文 SQL)。否则 HF 启动会 re-enqueue 本机 run 并 FatalError。
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 沙箱里跑命令、读写沙箱文件,调用 checkpoint 写接口沙箱外的真实文件系统不会被触及,但 HF 容器非持久,Sandbox 数据库路径务必放到持久卷。**
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 会话状态已改走 Supabase Postgres(`WORKFLOW_POSTGRES_URL`),不依赖容器内 `.workflow-data/`;免费 `cpu-basic` 档 48 小时无活动会 sleep,下次访问自动唤醒。
 
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
- // 自托管:会话/run 状态 Postgres(Supabase),再用默认 .workflow-data
29
- experimental: {
30
- workflow: {
31
- world: "@workflow/world-postgres",
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 /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
- * - POST /agentfs/api/sessions/:id/checkpoint body: { name }
10
- * - POST /agentfs/api/sessions/:id/restore body: { checkpoint }
11
- * - DELETE /agentfs/api/sessions/:id/checkpoint body: { name }
 
 
12
  *
13
  * 鉴权与 /eve 一致:Bearer AGENT_API_KEY,本机回环可走 localDev。
 
14
  * 无内嵌 HTML;本地浏览器见仓库根 `tools/agentfs-browser/index.html`。
15
  */
16
 
17
- import { defineChannel, GET, POST, DELETE } from "eve/channels";
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
- async function requireAuth(req: Request): Promise<Response | null> {
38
- const result = await routeAuth(req, auth);
39
- if (result instanceof Response) return result;
40
- return null;
41
  }
42
 
43
- async function sessionIdFromParams(
44
- args: { params: Readonly<Record<string, string>> },
45
- ): Promise<string | Response> {
46
- const id = args.params.id;
47
- if (!id) {
48
- return Response.json(
49
- { ok: false, error: { code: "missing_id", message: "缺少 session id" } },
50
- { status: 400 },
51
- );
52
- }
53
- return id;
 
 
 
54
  }
55
 
56
- async function handleSessions(req: Request): Promise<Response> {
57
- const denied = await requireAuth(req);
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
- async function handleTree(
77
- req: Request,
78
- args: { params: Readonly<Record<string, string>> },
79
- ): Promise<Response> {
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
- async function handleFile(
 
95
  req: Request,
96
- args: { params: Readonly<Record<string, string>> },
97
- ): Promise<Response> {
98
- const denied = await requireAuth(req);
99
- if (denied) return denied;
100
- const id = await sessionIdFromParams(args);
101
- if (id instanceof Response) return id;
102
- try {
103
- const url = new URL(req.url);
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
- // ─── Checkpoint ──────────────────────────────────────────────────────
119
 
120
- async function handleCreateCheckpoint(
121
- req: Request,
122
- args: { params: Readonly<Record<string, string>> },
123
- ): Promise<Response> {
124
- const denied = await requireAuth(req);
125
- if (denied) return denied;
126
- const id = await sessionIdFromParams(args);
 
 
 
 
 
 
 
 
127
  if (id instanceof Response) return id;
128
- try {
129
- const body: { name?: string } = await req.json().catch(() => ({}));
130
- if (!body.name || typeof body.name !== "string" || body.name.length === 0) {
131
- return Response.json(
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
- async function handleListCheckpoints(
144
- req: Request,
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
- try {
 
 
 
 
 
 
 
 
 
152
  const checkpoints = await listCheckpoints(id);
153
  return Response.json({ ok: true, checkpoints });
154
- } catch (err) {
155
- return jsonError(err);
156
- }
157
- }
158
 
159
- async function handleRestoreCheckpoint(
160
- req: Request,
161
- args: { params: Readonly<Record<string, string>> },
162
- ): Promise<Response> {
163
- const denied = await requireAuth(req);
164
- if (denied) return denied;
165
- const id = await sessionIdFromParams(args);
166
- if (id instanceof Response) return id;
167
- try {
168
- const body: { checkpoint?: string } = await req.json().catch(() => ({}));
169
- if (!body.checkpoint || typeof body.checkpoint !== "string" || body.checkpoint.length === 0) {
170
- return Response.json(
171
- { ok: false, error: { code: "missing_checkpoint", message: "缺少 checkpoint 字段" } },
172
- { status: 400 },
173
- );
174
- }
175
- const result = await restoreCheckpoint(id, body.checkpoint);
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  return Response.json({ ok: true, ...result });
177
- } catch (err) {
178
- return jsonError(err);
179
- }
180
- }
181
 
182
- async function handleDeleteCheckpoint(
183
- req: Request,
184
- args: { params: Readonly<Record<string, string>> },
185
- ): Promise<Response> {
186
- const denied = await requireAuth(req);
187
- if (denied) return denied;
188
- const id = await sessionIdFromParams(args);
189
- if (id instanceof Response) return id;
190
- try {
191
- const body: { name?: string } = await req.json().catch(() => ({}));
192
- if (!body.name || typeof body.name !== "string" || body.name.length === 0) {
193
- return Response.json(
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
- } catch (err) {
201
- return jsonError(err);
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
- // HF Space 直连端点(/spaces/.../mcp 会 404;与 PROJECT_RULES 一致)
5
- url: "https://hunian003-message.hf.space/mcp",
6
- description: "Message 功能中心:浏览器自动化(导航、点击、填表、截图)、沙盒文件操作与命令执行、图片 OCR 文字识别、音频转文字、小红书/微博内容提取。",
 
 
 
 
 
 
 
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 调试与存档:列出 session 库、浏览目录、读取文件预览,
3
- * 以及 session 文件系统存档点(checkpoint 创建 / 列表 / 回退 / 删除)。
4
- * agentfs channel 与外部前端对接。写操作仅限 checkpoint API。
 
 
 
 
 
5
  */
6
 
7
- import { readdir, stat, mkdir, rm } from "node:fs/promises";
8
- import { basename, dirname, join, resolve } from "node:path";
9
-
10
- import { AgentFS } from "agentfs-sdk";
11
- import type { FileSystem } from "agentfs-sdk";
12
-
13
- /** 与 sandbox.ts 一致的后端名与数据布局。 */
14
- export const AGENTFS_BACKEND_NAME = "agentfs-just-bash";
15
-
16
- /** 单文件预览上限(字节),超出只返回 meta + 截断提示。 */
17
- export const AGENTFS_DEBUG_MAX_FILE_BYTES = 256 * 1024;
18
-
19
- /** 存档点名允许字符(中文 + 字母数字 + 部分符号)。 */
20
- export const CHECKPOINT_NAME_RE = /^[a-zA-Z0-9._\-\u4e00-\u9fff]{1,100}$/;
21
-
22
- /** sessionKey 文件名允许的字符(与 eve sanitizeRuntimeSandboxKey 对齐并略放宽)。 */
23
- const SESSION_ID_RE = /^[a-zA-Z0-9._-]{1,200}$/;
24
-
25
- export type SessionDbInfo = {
26
- id: string;
27
- dbPath: string;
28
- sizeBytes: number;
29
- mtimeMs: number;
30
- };
31
-
32
- export type TreeEntry = {
33
- name: string;
34
- path: string;
35
- type: "file" | "directory" | "symlink" | "other";
36
- size: number;
37
- mtime: number;
38
- };
39
-
40
- export type FilePreview = {
41
- path: string;
42
- size: number;
43
- mtime: number;
44
- encoding: "utf-8" | "binary";
45
- truncated: boolean;
46
- content: string;
47
- byteLength: number;
48
- };
49
-
50
- /** 数据目录根:EVEAGENT_DATA_DIR 优先,否则 <cwd>/data。 */
51
- export function resolveDataDir(): string {
52
- const fromEnv = process.env.EVEAGENT_DATA_DIR?.trim();
53
- if (fromEnv) return resolve(fromEnv);
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 SandboxBackend just-bash 的虚拟文件系统落到 AgentFS 上
3
  *
4
- * 单文件后端,无子包依赖。实现 eve 0.22.x 的 `SandboxBackend` 契约:
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
- * 数据目录布局`<dataDir>/agentfs/<backendName>/`):
11
- * templates/<templateKey>.db 模板库(prewarm 用;当前 evework bootstrap,目录空)
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
- // 优先级 1:进程内同 sessionKey 命中(防重复 open 同一 DB)
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
- await maybePruneStaleSessions(runtimeContext.appRoot);
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
- // `node` 也能 import 此文件——避免 Nitro 把默认导出当函数调用失败
677
- export { agentfsJustBashBackend };
678
- export const backendName = BACKEND_NAME;
679
- export type { SandboxBackendRuntimeContext };
680
 
681
- export default defineSandbox({ backend: agentfsJustBashBackend() });
 
 
 
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
- `workflow_url=${process.env.WORKFLOW_POSTGRES_URL ? "set" : "MISSING"} 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"}`,
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);