@aalis/plugin-storage-local 0.12.2 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,34 +1,38 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { createReadStream, watch as fsWatch, statSync } from 'node:fs';
3
3
  import { chmod, lstat, mkdir, open, readdir, readFile, realpath, rename, rm, stat, unlink, writeFile, } from 'node:fs/promises';
4
- import { basename, dirname, extname, isAbsolute, relative, resolve, sep } from 'node:path';
4
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
5
+ import { checkpoint } from '@aalis/api-checkpoint';
5
6
  import { doctor } from '@aalis/api-doctor';
7
+ import { hostPaths } from '@aalis/api-host-paths';
6
8
  import { storage, } from '@aalis/api-storage';
7
9
  import { config, definePlugin, lifecycle, logger, optional, provide, services, } from '@aalis/core';
8
10
  import { defineConfig, parseConfig } from '@aalis/schema-config';
11
+ import { TextError, textOf } from '@aalis/schema-i18n';
12
+ import { t } from './texts.js';
9
13
  const PLUGIN_NAME = '@aalis/plugin-storage-local';
10
14
  /**
11
- * 这个插件不是沙箱。它做三件事:
15
+ * 这个插件不是沙箱。它做四件事:
12
16
  *
13
- * 1. 给若干本机目录起稳定的名字(roots 数组里声明,包括默认 workspace/data/tmp/
14
- * pluginData/logs,以及用户加的任何根),让上层用 URI 表示文件,避免到处写
15
- * 宿主机绝对路径。
17
+ * 1. 给若干本机目录起稳定的名字:插件自己的数据在按家目录布局派生的内置根里,文件工具用的根在 roots 数组里声明
18
+ * (缺省只有 workspace),让上层用 URI 表示文件,避免到处写宿主机绝对路径。
16
19
  * 2. 在每条 API 内对 `..` 穿越做规范化与校验,防止"workspace:/../../etc/passwd"
17
20
  * 这类逻辑越界 bug。
18
21
  * 3. 把所有读/写/删过一道 logger,作为统一审计点。
22
+ * 4. 宿主控制文件的执行点兜底:不管经哪个根进来,密钥文件读写都拒,主配置拒写(见 {@link HostFileGuard})。
19
23
  *
20
24
  * 它无法阻止 run_python / shell 等子进程在拿到 cwd 之后访问 OS 用户可访问的任何文件——
21
25
  * 这种隔离只能靠 OS 用户权限或容器层。请按这个边界来理解和配置。
22
26
  *
23
- * 关于 browsable:仅是给"文件浏览器类 UI"的 hint。当前 plugin-webui-server 的文件页
24
- * 实际只显示其 fileRoot 配置指向的那一个根(默认 workspace),其它根的 browsable=true
25
- * 不会让它们出现在文件页里——那些根仅供 agent/工具按 URI 寻址使用。
27
+ * 关于 browsable:仅是给"文件浏览器类 UI"的 hint。WebUI 没有文件浏览页,不读它;
28
+ * 这些根供 agent 与工具按 URI 寻址。
26
29
  *
27
- * 当 agent 需要访问"内置 5 个根之外"的目录时:
30
+ * 当 agent 需要访问默认根之外的目录时:
28
31
  * - 推荐:在 roots 里加一条具名根(如 share / project_x),路径限定到目标目录
29
32
  * - 一刀切:在 roots 里加 { name: host, path: '/' },agent 即可用 host:/绝对路径 访问
30
33
  * 宿主机任何位置(高危,启动时会有 WARN 日志)
31
34
  */
35
+ /** 用户 roots 缺省时注册的根:只有文件工具的工作根。插件自己的数据都在下面的内部路由根里 */
32
36
  const DEFAULT_ROOTS = [
33
37
  {
34
38
  name: 'workspace',
@@ -40,94 +44,85 @@ const DEFAULT_ROOTS = [
40
44
  writable: true,
41
45
  deletable: true,
42
46
  },
43
- {
44
- name: 'data',
45
- path: 'data',
46
- label: 'Data',
47
- kind: 'data',
48
- browsable: false,
49
- readable: true,
50
- writable: true,
51
- // 可删:/clear 的附件清理(data/images 等)依赖它,关掉会让开箱用户连手动清理都失败。
52
- // deletable 是能力位不是权限闸——agent 侧删除面由各工具自身档位把关
53
- // (file_delete=restricted+confirm+allowedRoots、skill_delete=sensitive 等,档位各异)。
54
- deletable: true,
55
- },
56
- {
57
- name: 'tmp',
58
- path: 'workspace/.tmp',
59
- label: '临时文件',
60
- kind: 'tmp',
61
- browsable: false,
62
- readable: true,
63
- writable: true,
64
- deletable: true,
65
- },
66
- {
67
- name: 'pluginData',
68
- path: 'data/plugins',
69
- label: '插件数据',
70
- kind: 'pluginData',
71
- browsable: false,
72
- readable: true,
73
- writable: true,
74
- deletable: true,
75
- },
76
- {
77
- name: 'logs',
78
- path: 'data',
79
- label: '日志',
80
- kind: 'logs',
81
- browsable: false,
82
- readable: true,
83
- writable: false,
84
- deletable: false,
85
- },
86
47
  // 高危直通:取消注释或改 enabled 字段无意义——存在即注册。如需 host:/ 直通根,把下行加进 roots:
87
48
  // { name: 'host', path: '/', label: '宿主机根', kind: 'host', browsable: false, readable: true, writable: false, deletable: false },
88
49
  ];
50
+ /** 插件读写、可删的内部根的权限位 */
51
+ const PRIVATE = { browsable: false, readable: true, writable: true, deletable: true };
52
+ /**
53
+ * 插件内置的内部路由根:按家目录布局(host-paths)派生,不受用户 roots 影响,不在文件页出现。在用户根之后注册;
54
+ * 用户 roots 里与内部根同名的项被跳过并告警(内部根优先)。见计划书「插件数据约定」。
55
+ * - data:插件状态(数据库、向量库、checkpoint、会话与附件……)。附件句柄里的根名 data 保留,映射到 state/,
56
+ * 不改写历史。可删:/clear 的附件清理依赖它;删除面由各工具自身档位把关。
57
+ * - pluginData:各插件的私有数据目录 state/plugins。
58
+ * - tmp、cache:临时区与缓存;logs:runtime 写文件日志的目录,WebUI 与 CLI 的日志页从这里读,只读。
59
+ * - personas、skills、workflows:self 下按用途分开的三个根,一个根里的路径拼接失误到不了另一个根。
60
+ * - paper:白纸枢纽存放远端任务成品;public:发布服务存放审核通过的作品,作品站从这里读出来部署。
61
+ * kind 供 checkpoint 等按类判断:data、pluginData、tmp、cache、logs、paper、public、self 都不记账
62
+ * (self 等访问层限定写入者后再记,见 plugin-checkpoint)。
63
+ */
64
+ function internalRoots(paths) {
65
+ return [
66
+ { name: 'data', path: paths.state, label: '状态', kind: 'data', ...PRIVATE },
67
+ { name: 'pluginData', path: join(paths.state, 'plugins'), label: '插件数据', kind: 'pluginData', ...PRIVATE },
68
+ { name: 'tmp', path: paths.tmp, label: '临时文件', kind: 'tmp', ...PRIVATE },
69
+ { name: 'cache', path: paths.cache, label: '缓存', kind: 'cache', ...PRIVATE },
70
+ {
71
+ name: 'logs',
72
+ path: paths.logs,
73
+ label: '日志',
74
+ kind: 'logs',
75
+ browsable: false,
76
+ readable: true,
77
+ writable: false,
78
+ deletable: false,
79
+ },
80
+ { name: 'personas', path: paths.self.personas, label: '人设卡', kind: 'self', ...PRIVATE },
81
+ { name: 'skills', path: paths.self.skills, label: '技能', kind: 'self', ...PRIVATE },
82
+ { name: 'workflows', path: paths.self.workflows, label: '编排定义', kind: 'self', ...PRIVATE },
83
+ { name: 'paper', path: paths.stage.paper, label: '白纸', kind: 'paper', ...PRIVATE },
84
+ { name: 'public', path: paths.stage.public, label: '公开作品', kind: 'public', ...PRIVATE },
85
+ ];
86
+ }
89
87
  const configSchema = defineConfig({
90
88
  roots: {
91
89
  type: 'array',
92
- label: '存储根目录',
93
- description: '所有可用根都在这里声明(包括 workspace/data/tmp 等内置根)。' +
94
- '直接编辑这个数组:删除不要的、修改 path、加自定义根、加 host:/ 直通根。' +
95
- 'browsable 是给 WebUI 等浏览器类组件的 hint(注意:当前 WebUI 文件页固定显示 fileRoot 配置指向的那一个根,' +
96
- '其它根仅作为工具/agent 寻址使用)。',
90
+ label: t('rootsLabel'),
91
+ description: t('rootsDescription'),
97
92
  default: DEFAULT_ROOTS,
98
93
  onInvalid: 'error',
99
94
  items: {
100
95
  name: {
101
96
  type: 'string',
102
- label: '根名 (URI scheme)',
97
+ label: t('nameLabel'),
103
98
  required: true,
104
99
  onInvalid: 'error',
105
- description: '只允许字母/数字/下划线/连字符,且需以字母开头;如 workspace、share',
100
+ description: t('nameDescription'),
106
101
  },
107
102
  path: {
108
103
  type: 'string',
109
- label: '本机路径',
104
+ label: t('pathLabel'),
110
105
  required: true,
111
106
  onInvalid: 'error',
112
- description: '可绝对,亦可相对项目根;不存在时自动创建。指向 / 即注册宿主机直通根(高危)。',
107
+ description: t('pathDescription'),
113
108
  },
114
- label: { type: 'string', label: '展示名称', default: '' },
109
+ label: { type: 'string', label: t('labelLabel'), default: '' },
115
110
  kind: {
116
111
  type: 'string',
117
- label: '类型标签',
112
+ label: t('kindLabel'),
118
113
  default: 'custom',
119
- description: '语义提示:workspace / data / tmp / pluginData / logs / custom / external / shared / host',
114
+ description: t('kindDescription'),
120
115
  },
121
116
  browsable: {
122
117
  type: 'boolean',
123
- label: 'WebUI 浏览器可见 (hint)',
118
+ label: t('browsableLabel'),
124
119
  default: false,
125
120
  onInvalid: 'error',
126
- description: 'hint:当前实现下仅当 webui-server.fileRoot 指向本根时此开关才生效',
121
+ description: t('browsableDescription'),
127
122
  },
128
- readable: { type: 'boolean', label: '允许读', default: true, onInvalid: 'error' },
129
- writable: { type: 'boolean', label: '允许写', default: false, onInvalid: 'error' },
130
- deletable: { type: 'boolean', label: '允许删除', default: false, onInvalid: 'error' },
123
+ readable: { type: 'boolean', label: t('readableLabel'), default: true, onInvalid: 'error' },
124
+ writable: { type: 'boolean', label: t('writableLabel'), default: false, onInvalid: 'error' },
125
+ deletable: { type: 'boolean', label: t('deletableLabel'), default: false, onInvalid: 'error' },
131
126
  },
132
127
  },
133
128
  });
@@ -145,29 +140,110 @@ function toUri(root, relPath) {
145
140
  function parseUri(uri) {
146
141
  const idx = uri.indexOf(':/');
147
142
  if (idx <= 0)
148
- throw new Error(`存储 URI 不合法: ${uri}`);
143
+ throw new TextError(t('badUri', { uri }));
149
144
  const root = uri.slice(0, idx);
150
145
  const relPath = normalizeRelPath(uri.slice(idx + 2));
151
146
  return { root, relPath };
152
147
  }
153
148
  /**
154
- * Per-root 存储服务:每个 root 一个实例 + 一个 ServiceContainer entry。
155
- *
156
- * 与 service-granularity 整体方向对齐:每个 root 的真实权限由 StorageRootInfo 的
157
- * readable/writable/deletable 位携带;storage-api 网关按位路由,按 URI 调度交给
158
- * `createStorageGateway(storage)` helper。
159
- *
160
- * 自包含设计:URI→root 分发不依赖外部 router 插件,单文件即可理解整条数据流。
149
+ * 宿主控制文件的执行点兜底:密钥文件读写都拒,主配置拒写,只许 runtime 自己读写它们(见计划书「执行点兜底」)。
150
+ * 不管目标经哪个根进来都查:
151
+ * - 文件本身:按 (dev, ino) 比,硬链接与符号链接都挡得住;文件还不存在时按真实路径比(不分大小写,
152
+ * macOS 与 Windows 的卷默认不分),落在控制文件路径之下的也拒(不许把它建成目录);
153
+ * ino 按 bigint 取:Windows 的 64 位文件 ID 转成 number 会丢低位,不同文件可能撞号;
154
+ * - 整个子树:删除、移动、改名一个目录,或以写、删的方式把目录的本地路径交给别的工具时,按目录身份 (dev, ino)
155
+ * 看它是不是控制文件的祖先目录。按身份而不按路径比:macOS 的 firmlink 与 bind mount 给同一个目录不同的路径。
156
+ * 控制文件每次现查:主配置保存时整份换掉,inode 会变。
157
+ */
158
+ class HostFileGuard {
159
+ files;
160
+ constructor(files) {
161
+ this.files = files;
162
+ }
163
+ /**
164
+ * `read`:读内容(只挡密钥文件);`write`:写、删、移动(两个都挡);`subtree`:目标是目录时连子树一起查。
165
+ */
166
+ async check(abs, mode, subtree = false) {
167
+ const guarded = mode === 'read' ? [this.files.secrets] : [this.files.secrets, this.files.config];
168
+ const target = await identify(abs);
169
+ for (const file of guarded) {
170
+ const control = await identify(file);
171
+ const sameFile = target.stat !== undefined &&
172
+ control.stat !== undefined &&
173
+ target.stat.dev === control.stat.dev &&
174
+ target.stat.ino === control.stat.ino;
175
+ // 含路径相同(isInside 对同一路径判内)
176
+ const underControl = isInside(foldCase(control.real), foldCase(target.real));
177
+ const containsControl = subtree && target.stat?.isDirectory() === true && (await ancestorIds(control.real)).has(idOf(target.stat));
178
+ if (sameFile || underControl) {
179
+ // 读时只查密钥文件:主配置只在写时拦
180
+ if (file !== this.files.secrets)
181
+ throw new TextError(t('writeConfig'));
182
+ throw new TextError(t(mode === 'read' ? 'readSecrets' : 'writeSecrets'));
183
+ }
184
+ if (containsControl)
185
+ throw new TextError(t('containsControl'));
186
+ }
187
+ }
188
+ }
189
+ const foldCase = (p) => p.toLowerCase();
190
+ const idOf = (s) => `${s.dev}:${s.ino}`;
191
+ /** 路径的真实路径与 stat;不存在时从最近的已存在祖先算真实路径、不给 stat */
192
+ async function identify(abs) {
193
+ try {
194
+ return { real: await realpath(abs), stat: await stat(abs, { bigint: true }) };
195
+ }
196
+ catch {
197
+ /* 不存在或读不了:往上找 */
198
+ }
199
+ const missing = [];
200
+ let dir = abs;
201
+ for (;;) {
202
+ missing.unshift(basename(dir));
203
+ const parent = dirname(dir);
204
+ if (parent === dir)
205
+ return { real: abs };
206
+ dir = parent;
207
+ try {
208
+ return { real: join(await realpath(dir), ...missing) };
209
+ }
210
+ catch {
211
+ /* 继续往上 */
212
+ }
213
+ }
214
+ }
215
+ /** 控制文件的各级祖先目录的身份(找得到的那些) */
216
+ async function ancestorIds(real) {
217
+ const ids = new Set();
218
+ let dir = dirname(real);
219
+ for (;;) {
220
+ try {
221
+ ids.add(idOf(await stat(dir, { bigint: true })));
222
+ }
223
+ catch {
224
+ /* 不存在的跳过 */
225
+ }
226
+ const parent = dirname(dir);
227
+ if (parent === dir)
228
+ return ids;
229
+ dir = parent;
230
+ }
231
+ }
232
+ /**
233
+ * 一个根的存储操作:真实权限由 StorageRootInfo 的 readable/writable/deletable 位携带,每个操作先核对。
234
+ * 不单独登记:{@link LocalStorage} 按 URI 的根名分给它。
161
235
  */
162
236
  class ScopedStorageService {
163
237
  root;
238
+ guard;
164
239
  logger;
165
240
  services;
166
241
  onDispose;
167
- constructor(root, logger, services,
242
+ constructor(root, guard, logger, services,
168
243
  /** 本次激活的清理登记:watch 建的 fs 监听器归提供者所有,提供者关闭时一并关闭 */
169
244
  onDispose) {
170
245
  this.root = root;
246
+ this.guard = guard;
171
247
  this.logger = logger;
172
248
  this.services = services;
173
249
  this.onDispose = onDispose;
@@ -199,13 +275,12 @@ class ScopedStorageService {
199
275
  this.recentLogAt.set(key, now);
200
276
  this.logger.debug(msg);
201
277
  }
202
- /** 懒解析 checkpoint 服务;仅当回合活跃时调用 beforeMutate */
278
+ /** 懒解析 checkpoint 服务,在场就调 beforeMutate:写入是否处在回合里、要不要快照由它判断 */
203
279
  async snapshot(uri, op, abs, targetUri) {
204
- // 按名动态查:checkpoint 的描述符在实现包里,而 checkpoint 自己要用存储——
205
- // 声明它会让两个实现包互指。动态查到的不算声明依赖,与这里需要的语义一致
206
- // (快照能力有就用、没有就直接落盘,不设激活闸)。
207
- const cp = this.services.get('checkpoint');
208
- if (!cp?.isActive())
280
+ // 动态查而不写进 uses:checkpoint 自己要用存储,声明它会让两者互为依赖。动态查到的不算声明依赖,
281
+ // 与这里需要的语义一致(快照能力有就用、没有就直接落盘,不设激活闸)。
282
+ const cp = this.services.get(checkpoint);
283
+ if (!cp)
209
284
  return;
210
285
  await cp.beforeMutate(uri, op, async () => {
211
286
  // 只有「目录」与「文件不存在」才返回 null —— checkpoint 据此判定为本回合新建。
@@ -234,7 +309,7 @@ class ScopedStorageService {
234
309
  const abs = await this.resolveExisting(relPath);
235
310
  const s = await stat(abs);
236
311
  if (!s.isDirectory())
237
- throw new Error('不是目录');
312
+ throw new TextError(t('notDirectory'));
238
313
  const dispUri = toUri(this.root.name, relPath);
239
314
  this.debugSampled(`list:${dispUri}`, `storage.list ${dispUri}`);
240
315
  const entries = await readdir(abs);
@@ -281,9 +356,10 @@ class ScopedStorageService {
281
356
  const relPath = this.parseSelfUri(uri);
282
357
  this.requirePermission('readable');
283
358
  const abs = await this.resolveExisting(relPath);
359
+ await this.guard.check(abs, 'read');
284
360
  const s = await stat(abs);
285
361
  if (s.isDirectory())
286
- throw new Error('不能读取目录');
362
+ throw new TextError(t('readDirectory'));
287
363
  const dispUri = toUri(this.root.name, relPath);
288
364
  this.debugSampled(`read:${dispUri}`, `storage.read ${dispUri} size=${s.size}`);
289
365
  return encoding ? readFile(abs, encoding) : readFile(abs);
@@ -293,9 +369,10 @@ class ScopedStorageService {
293
369
  const relPath = this.parseSelfUri(uri);
294
370
  this.requirePermission('readable');
295
371
  const abs = await this.resolveExisting(relPath);
372
+ await this.guard.check(abs, 'read');
296
373
  const s = await stat(abs);
297
374
  if (s.isDirectory())
298
- throw new Error('不能读取目录');
375
+ throw new TextError(t('readDirectory'));
299
376
  const from = Math.max(0, Math.floor(start));
300
377
  const to = Math.min(s.size, Math.floor(end));
301
378
  if (to <= from)
@@ -314,37 +391,39 @@ class ScopedStorageService {
314
391
  const relPath = this.parseSelfUri(uri);
315
392
  this.requirePermission('readable');
316
393
  const abs = await this.resolveExisting(relPath);
394
+ await this.guard.check(abs, 'read');
317
395
  const fileStat = await this.statFromAbs(abs, relPath);
318
396
  if (fileStat.isDirectory)
319
- throw new Error('不能下载目录');
397
+ throw new TextError(t('downloadDirectory'));
320
398
  this.logger.info(`storage.download ${toUri(this.root.name, relPath)} size=${fileStat.size}`);
321
399
  return { stream: createReadStream(abs), stat: fileStat };
322
400
  }
323
- async writeFile(uri, data) {
401
+ async writeFile(uri, data, options) {
324
402
  const relPath = this.parseSelfUri(uri);
325
403
  this.requirePermission('writable');
326
404
  if (!relPath)
327
- throw new Error('不能覆盖根目录');
405
+ throw new TextError(t('overwriteRoot'));
328
406
  const abs = await this.resolveForWrite(relPath);
407
+ await this.guard.check(abs, 'write');
329
408
  // 目标是目录 → 在快照之前就拒。写目录反正必失败(rename 覆盖不了非空目录),
330
409
  // 但放它走到 snapshot 会让 checkpoint 记一条幽灵 write-new(loadOriginal 对目录返回 null),
331
410
  // 回滚时按 write-new 把整棵目录删掉。
332
411
  const existing = await stat(abs).catch(() => undefined);
333
412
  if (existing?.isDirectory())
334
- throw new Error('不能覆盖目录');
413
+ throw new TextError(t('overwriteDirectory'));
335
414
  await this.snapshot(toUri(this.root.name, relPath), 'write', abs);
336
415
  await mkdir(dirname(abs), { recursive: true });
337
416
  // 原子写:先写临时文件再 rename(同分区原子覆盖),防崩溃/并发半写损坏关键持久化(users.json/scheduler-jobs 等)。
338
417
  // 随机段避免同进程同毫秒并发写同一路径撞出同名 tmp(一方 rename 后另一方 ENOENT,或串写对方内容)。
339
418
  const tmp = `${abs}.tmp.${process.pid}.${Date.now()}.${randomBytes(4).toString('hex')}`;
340
- // 覆盖已有文件时沿用它的权限位:tmp 按默认 mode 创建,rename 后目标继承之,
341
- // 可执行脚本写一次就从 755 掉到 644。chmod 打在 tmp 上而非 rename 后的目标,
342
- // 避开两步之间的窗口;目标不存在则保持默认,stat/chmod 失败不拖累写入本身。
343
- const prevMode = existing ? existing.mode & 0o777 : undefined;
419
+ // rename 后目标继承 tmp 的权限位:凭据一律 0600;覆盖已有文件时沿用它的权限位,否则可执行脚本写一次就从 755
420
+ // 掉到 644;其余按默认。权限位在建 tmp 时就给上,内容从不以更宽的权限落盘;chmod 再对齐被 umask 收窄的部分
421
+ //(只会比给定的窄),打在 tmp 上而非 rename 后的目标,避开两步之间的窗口,失败不拖累写入本身。
422
+ const mode = options?.secret ? 0o600 : existing ? existing.mode & 0o777 : undefined;
344
423
  try {
345
- await writeFile(tmp, data);
346
- if (prevMode !== undefined)
347
- await chmod(tmp, prevMode).catch(() => { });
424
+ await writeFile(tmp, data, { mode });
425
+ if (mode !== undefined)
426
+ await chmod(tmp, mode).catch(() => { });
348
427
  await rename(tmp, abs);
349
428
  }
350
429
  catch (err) {
@@ -355,18 +434,20 @@ class ScopedStorageService {
355
434
  }
356
435
  async rename(uri, newName) {
357
436
  if (!newName || newName.includes('/') || newName.includes('\\') || newName === '.' || newName === '..') {
358
- throw new Error('文件名不合法');
437
+ throw new TextError(t('badFileName'));
359
438
  }
360
439
  const relPath = this.parseSelfUri(uri);
361
440
  this.requirePermission('writable');
362
441
  if (!relPath)
363
- throw new Error('不能重命名根目录');
442
+ throw new TextError(t('renameRoot'));
364
443
  const abs = await this.resolveExisting(relPath);
365
444
  const newRel = normalizeRelPath(`${dirname(relPath) === '.' ? '' : dirname(relPath)}/${newName}`);
366
445
  const target = await this.resolveForWrite(newRel);
446
+ await this.guard.check(abs, 'write', true);
447
+ await this.guard.check(target, 'write');
367
448
  try {
368
449
  await lstat(target);
369
- throw new Error('目标名称已存在');
450
+ throw new TextError(t('nameExists'));
370
451
  }
371
452
  catch (err) {
372
453
  if (err.code !== 'ENOENT')
@@ -387,14 +468,16 @@ class ScopedStorageService {
387
468
  const toRel = this.parseSelfUri(targetUri); // 跨根 → root 不匹配抛错
388
469
  this.requirePermission('writable');
389
470
  if (!fromRel)
390
- throw new Error('不能移动根目录');
471
+ throw new TextError(t('moveRoot'));
391
472
  if (!toRel)
392
- throw new Error('目标不能是根目录');
473
+ throw new TextError(t('targetRoot'));
393
474
  const absFrom = await this.resolveExisting(fromRel);
394
475
  const absTo = await this.resolveForWrite(toRel);
476
+ await this.guard.check(absFrom, 'write', true);
477
+ await this.guard.check(absTo, 'write');
395
478
  try {
396
479
  await lstat(absTo);
397
- throw new Error('目标已存在');
480
+ throw new TextError(t('targetExists'));
398
481
  }
399
482
  catch (err) {
400
483
  if (err.code !== 'ENOENT')
@@ -414,8 +497,9 @@ class ScopedStorageService {
414
497
  const relPath = this.parseSelfUri(uri);
415
498
  this.requirePermission('writable');
416
499
  if (!relPath)
417
- throw new Error('根目录已存在,无需创建');
500
+ throw new TextError(t('rootExists'));
418
501
  const abs = await this.resolveForWrite(relPath);
502
+ await this.guard.check(abs, 'write');
419
503
  await mkdir(abs, { recursive: true }); // recursive:建全路径;已存在则 no-op(幂等)
420
504
  this.logger.info(`storage.mkdir ${toUri(this.root.name, relPath)}`);
421
505
  return toUri(this.root.name, relPath);
@@ -424,12 +508,13 @@ class ScopedStorageService {
424
508
  const relPath = this.parseSelfUri(uri);
425
509
  this.requirePermission('deletable');
426
510
  if (!relPath)
427
- throw new Error('不能删除根目录');
511
+ throw new TextError(t('deleteRoot'));
428
512
  const abs = await this.resolveExisting(relPath);
429
513
  // `sub/..` 这类解析回根本身的路径同样是删根——上面的字面空路径守卫挡不住
430
514
  // (isInside 对 rel==='' 判内、normalizeRelPath 不中和 `..`;审计发现的旁路)。
431
515
  if (abs === (await realpath(this.root.realPath)))
432
- throw new Error('不能删除根目录');
516
+ throw new TextError(t('deleteRoot'));
517
+ await this.guard.check(abs, 'write', true);
433
518
  await this.snapshot(toUri(this.root.name, relPath), 'delete', abs);
434
519
  await rm(abs, { recursive: true, force: false });
435
520
  this.logger.info(`storage.delete ${toUri(this.root.name, relPath)}`);
@@ -439,6 +524,9 @@ class ScopedStorageService {
439
524
  const permission = access === 'delete' ? 'deletable' : access === 'write' ? 'writable' : 'readable';
440
525
  this.requirePermission(permission);
441
526
  const abs = access === 'write' ? await this.resolveForWrite(relPath) : await this.resolveExisting(relPath);
527
+ // 以写、删的方式交出目录时连子树一起查。以读的方式交出目录(命令的工作目录等)不查子树:拿到目录的工具
528
+ // 本来就能按绝对路径读任何文件,挡它们靠沙箱的拒读清单,在这里拒只会让家目录的上级目录用不了
529
+ await this.guard.check(abs, access === 'read' ? 'read' : 'write', access !== 'read');
442
530
  this.logger.debug(`storage.resolveLocalPath ${toUri(this.root.name, relPath)} access=${access}`);
443
531
  return abs;
444
532
  }
@@ -454,7 +542,7 @@ class ScopedStorageService {
454
542
  this.requirePermission('readable');
455
543
  const absBase = resolve(this.root.realPath, normalizeRelPath(relPath));
456
544
  if (!isInside(this.root.realPath, absBase)) {
457
- throw new Error('路径不合法');
545
+ throw new TextError(t('badPath'));
458
546
  }
459
547
  const debounceMs = 50;
460
548
  const pending = new Map();
@@ -547,37 +635,37 @@ class ScopedStorageService {
547
635
  parseSelfUri(uri) {
548
636
  const { root, relPath } = parseUri(uri);
549
637
  if (root !== this.root.name) {
550
- throw new Error(`URI 根 "${root}" 不属于本 entry (${this.root.name})`);
638
+ throw new TextError(t('otherRoot', { root, name: this.root.name }));
551
639
  }
552
640
  return relPath;
553
641
  }
554
642
  requirePermission(permission) {
555
643
  if (!this.root[permission]) {
556
- throw new Error(`存储根 ${this.root.name} 不允许该操作 (${permission})`);
644
+ throw new TextError(t('notAllowed', { name: this.root.name, permission }));
557
645
  }
558
646
  }
559
647
  async resolveExisting(relPath) {
560
648
  const lexical = resolve(this.root.realPath, normalizeRelPath(relPath));
561
649
  if (!isInside(this.root.realPath, lexical))
562
- throw new Error('路径不合法');
650
+ throw new TextError(t('badPath'));
563
651
  const resolved = await realpath(lexical);
564
652
  if (!isInside(this.root.realPath, resolved))
565
- throw new Error('路径不合法');
653
+ throw new TextError(t('badPath'));
566
654
  return resolved;
567
655
  }
568
656
  async resolveForWrite(relPath) {
569
657
  const normalized = normalizeRelPath(relPath);
570
658
  const lexical = resolve(this.root.realPath, normalized);
571
659
  if (!isInside(this.root.realPath, lexical))
572
- throw new Error('路径不合法');
660
+ throw new TextError(t('badPath'));
573
661
  const existingParent = await this.findExistingParent(dirname(lexical));
574
662
  const parent = await realpath(existingParent);
575
663
  if (!isInside(this.root.realPath, parent))
576
- throw new Error('路径不合法');
664
+ throw new TextError(t('badPath'));
577
665
  try {
578
666
  const target = await lstat(lexical);
579
667
  if (target.isSymbolicLink())
580
- throw new Error('不允许写入符号链接');
668
+ throw new TextError(t('symlink'));
581
669
  }
582
670
  catch (err) {
583
671
  if (err.code !== 'ENOENT')
@@ -591,7 +679,7 @@ class ScopedStorageService {
591
679
  try {
592
680
  const s = await lstat(current);
593
681
  if (!s.isDirectory())
594
- throw new Error('父路径不是目录');
682
+ throw new TextError(t('parentNotDirectory'));
595
683
  return current;
596
684
  }
597
685
  catch (err) {
@@ -623,8 +711,68 @@ class ScopedStorageService {
623
711
  return info;
624
712
  }
625
713
  }
626
- async function createRoot(name, label, kind, rootPath, options) {
627
- const abs = resolve(process.cwd(), rootPath);
714
+ /**
715
+ * 本插件登记的唯一一个 storage 提供者:按 URI 的根名分给各根的 {@link ScopedStorageService}。根表在登记之前就建好,
716
+ * 依赖方激活时所有根都在。根名不认识时同步抛错并列出已有的根
717
+ */
718
+ class LocalStorage {
719
+ roots;
720
+ constructor(roots) {
721
+ this.roots = roots;
722
+ }
723
+ route(uri) {
724
+ const { root } = parseUri(uri);
725
+ const scoped = this.roots.get(root);
726
+ if (!scoped) {
727
+ const names = [...this.roots.keys()];
728
+ throw new TextError(names.length > 0 ? t('unknownRoot', { root, roots: names.join(', ') }) : t('unknownRootNone', { root }));
729
+ }
730
+ return scoped;
731
+ }
732
+ listRoots() {
733
+ return [...this.roots.values()].flatMap(scoped => scoped.listRoots());
734
+ }
735
+ list(uri) {
736
+ return this.route(uri).list(uri);
737
+ }
738
+ stat(uri) {
739
+ return this.route(uri).stat(uri);
740
+ }
741
+ readFile(uri, encoding) {
742
+ return this.route(uri).readFile(uri, encoding);
743
+ }
744
+ readFileRange(uri, start, end) {
745
+ return this.route(uri).readFileRange(uri, start, end);
746
+ }
747
+ createReadStream(uri) {
748
+ return this.route(uri).createReadStream(uri);
749
+ }
750
+ writeFile(uri, data, options) {
751
+ return this.route(uri).writeFile(uri, data, options);
752
+ }
753
+ rename(uri, newName) {
754
+ return this.route(uri).rename(uri, newName);
755
+ }
756
+ /** 只在同一个根里移动:目标的根由那个根自己核对 */
757
+ move(fromUri, toUri) {
758
+ return this.route(fromUri).move(fromUri, toUri);
759
+ }
760
+ mkdir(uri) {
761
+ return this.route(uri).mkdir(uri);
762
+ }
763
+ delete(uri) {
764
+ return this.route(uri).delete(uri);
765
+ }
766
+ resolveLocalPath(uri, access) {
767
+ return this.route(uri).resolveLocalPath(uri, access);
768
+ }
769
+ watch(uri, listener) {
770
+ return this.route(uri).watch(uri, listener);
771
+ }
772
+ }
773
+ async function createRoot(home, name, label, kind, rootPath, options) {
774
+ // 相对路径按家目录解析:进程的 cwd 不带语义
775
+ const abs = resolve(home, rootPath);
628
776
  // path 已是文件系统根('/')等存在的目录时不需要创建;其它情况 mkdir -p
629
777
  if (rootPath !== '/') {
630
778
  await mkdir(abs, { recursive: true });
@@ -638,11 +786,41 @@ async function createRoot(name, label, kind, rootPath, options) {
638
786
  };
639
787
  }
640
788
  const ROOT_NAME_RE = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
641
- /** 从解析后的 roots 构造可用根。显式空数组沿用内置根。 */
642
- async function buildRoots(parsedRoots, logger) {
789
+ /** 按一条根配置建目录、解析真实路径并打注册日志;失败时告警并返回 undefined */
790
+ async function openRoot(home, item, logger) {
791
+ const { name } = item;
792
+ try {
793
+ const root = await createRoot(home, name, item.label || name, item.kind || 'custom', item.path, {
794
+ browsable: item.browsable === true,
795
+ readable: item.readable !== false,
796
+ writable: item.writable === true,
797
+ deletable: item.deletable === true,
798
+ });
799
+ // 按路径**深度**判定,不按字符串长度:`/x` 只是个普通目录却因为长度 2 被误报,
800
+ // 而 `/etc`(长度 4)这种真该提醒的反而漏报。深度 ≤ 1 才是「根或根下一层」。
801
+ // 这只影响日志级别与提示文案,不参与任何 grant/deny —— 能访问什么由 realPath
802
+ // 加根的 read/write/delete 位决定,与本判断无关。
803
+ const depth = root.realPath.split('/').filter(Boolean).length;
804
+ const isHostScope = depth <= 1;
805
+ const log = isHostScope ? logger.warn.bind(logger) : logger.info.bind(logger);
806
+ log(`root ${name}:/ -> ${root.realPath}` +
807
+ ` (browsable=${root.browsable} read=${root.readable} write=${root.writable} delete=${root.deletable})` +
808
+ (isHostScope ? ' 注意:该根接近/等于文件系统根,agent 通过此根可访问宿主机大量文件' : ''));
809
+ return root;
810
+ }
811
+ catch {
812
+ logger.warn(`root ${name} 注册失败,请检查 path 与目录权限`);
813
+ return undefined;
814
+ }
815
+ }
816
+ /**
817
+ * 从已校验的 roots 构造用户根;空数组时注册默认的 workspace 根。与内部根同名的项跳过并告警,告警里写明配置的路径
818
+ * 与内部根实际指向的位置:升级前把 data、tmp 等根指到别处的配置,那里的数据要搬过去。
819
+ */
820
+ async function buildRoots(home, parsedRoots, internal, logger) {
643
821
  let entries = parsedRoots.slice();
644
822
  if (entries.length === 0) {
645
- logger.warn('storage roots 配置为空,将注册默认 5 个内置根(workspace/data/tmp/pluginData/logs)');
823
+ logger.warn('storage roots 配置为空,将注册默认根 workspace');
646
824
  entries = DEFAULT_ROOTS.slice();
647
825
  }
648
826
  const out = [];
@@ -658,75 +836,67 @@ async function buildRoots(parsedRoots, logger) {
658
836
  logger.warn('roots 跳过非法根名(仅允许字母/数字/下划线/连字符,且需以字母开头)');
659
837
  continue;
660
838
  }
839
+ const internalPath = internal.get(name);
840
+ if (internalPath !== undefined) {
841
+ logger.warn(`roots 跳过 ${name}(配置的 path:${path}):与插件内部根同名,${name}:/ 指向 ${internalPath};` +
842
+ '配置里的这一项可以删掉,原来放在别处的数据要搬到这里');
843
+ continue;
844
+ }
661
845
  if (seen.has(name)) {
662
846
  logger.warn(`roots 跳过重复根名 ${name}`);
663
847
  continue;
664
848
  }
665
849
  seen.add(name);
666
- try {
667
- const root = await createRoot(name, item.label || name, item.kind || 'custom', path, {
668
- browsable: item.browsable === true,
669
- readable: item.readable !== false,
670
- writable: item.writable === true,
671
- deletable: item.deletable === true,
672
- });
673
- // 按路径**深度**判定,不按字符串长度:`/x` 只是个普通目录却因为长度 2 被误报,
674
- // 而 `/etc`(长度 4)这种真该提醒的反而漏报。深度 ≤ 1 才是「根或根下一层」。
675
- // 这只影响日志级别与提示文案,不参与任何 grant/deny —— 能访问什么由 realPath
676
- // 加根的 read/write/delete 位决定,与本判断无关。
677
- const depth = root.realPath.split('/').filter(Boolean).length;
678
- const isHostScope = depth <= 1;
679
- const log = isHostScope ? logger.warn.bind(logger) : logger.info.bind(logger);
680
- log(`root ${name}:/ -> ${root.realPath}` +
681
- ` (browsable=${root.browsable} read=${root.readable} write=${root.writable} delete=${root.deletable})` +
682
- (isHostScope ? ' ⚠ 该根接近/等于文件系统根,agent 通过此根可访问宿主机大量文件' : ''));
850
+ const root = await openRoot(home, { ...item, name, path }, logger);
851
+ if (root)
683
852
  out.push(root);
684
- }
685
- catch {
686
- logger.warn(`root ${name} 注册失败,请检查 path 与目录权限`);
687
- }
688
853
  }
689
854
  return out;
690
855
  }
691
- const uses = { provide, services, logger, lifecycle, config, doctor: optional(doctor) };
856
+ const uses = { provide, services, logger, lifecycle, config, paths: hostPaths, doctor: optional(doctor) };
692
857
  export default definePlugin({
693
858
  name: PLUGIN_NAME,
694
- displayName: '本地存储根(命名 + 路径解析)',
859
+ displayName: t('displayName'),
695
860
  subsystem: 'storage',
696
861
  configSchema,
697
862
  provides: [storage],
698
863
  uses,
699
864
  async apply(caps) {
700
- const logger = caps.logger.child('storage');
701
- const cfg = parseConfig(configSchema, caps.config, logger);
702
- const roots = await buildRoots(cfg.roots, logger);
865
+ const logger = caps.logger.use.child('storage');
866
+ const cfg = parseConfig(configSchema, caps.config.use, logger);
867
+ const paths = caps.paths.use;
868
+ const home = paths.home;
869
+ const guard = new HostFileGuard({ secrets: paths.secrets, config: paths.config });
870
+ const internal = internalRoots(paths);
871
+ const roots = await buildRoots(home, cfg.roots, new Map(internal.map(r => [r.name, r.path])), logger);
872
+ // 插件自己的数据都在内部根里;用户根一个都没有时只是缺了文件工具的工作根,照常提供存储
703
873
  if (roots.length === 0)
704
- throw new Error('plugin-storage-local: 没有任何可用根,请检查 roots 配置');
705
- // service-granularity:每个 root 单独注册一个 entry。
706
- // - entryId = `${实例 id}/${root.name}`,便于在 ServiceContainer 中按根定位、避免跨实例同名冲突;
707
- // - 权限由 root 的 readable/writable/deletable 位携带(StorageRootInfo),storage-api 网关按位路由;
708
- // - URI 路由由 createStorageGateway(storage) helper 在调用方完成,不再有 facade。
874
+ logger.warn('没有可用的用户根(文件工具的工作根),请检查 roots 配置');
875
+ for (const item of internal) {
876
+ const root = await openRoot(home, item, logger);
877
+ if (root)
878
+ roots.push(root);
879
+ }
880
+ // 全部根就绪之后整体登记一个提供者,根在它内部路由:依赖方激活时就看得到每一个根
881
+ const scoped = new Map();
709
882
  for (const root of roots) {
710
883
  if (!root.readable && !root.writable && !root.deletable) {
711
884
  logger.warn(`root ${root.name} 没有任何权限位,跳过注册`);
712
885
  continue;
713
886
  }
714
- const scoped = new ScopedStorageService(root, logger, caps.services, caps.lifecycle.onDispose);
715
- caps.provide(storage, scoped, {
716
- entryId: `${caps.lifecycle.id}/${root.name}`,
717
- label: root.label || `本地根 ${root.name}`,
718
- });
887
+ scoped.set(root.name, new ScopedStorageService(root, guard, logger, caps.services.use, caps.lifecycle.use.onDispose));
719
888
  }
889
+ caps.provide.use(storage, new LocalStorage(scoped), { label: t('provideLabel') });
720
890
  // 诊断检查项:探测每个 writable root 是否真的可写。
721
- // 由 storage 插件按当前 roots 配置上报,而非 doctor 硬编码 fs.data:data/ 仅是默认 root 之一,
722
- // 不应由 doctor 单独「祝福」;按 roots 上报才能反映用户自定义根(如 host:/、project_x)的真实状态。
723
- caps.doctor.registerCheck({
891
+ // 由 storage 插件按全部根(含内置根)上报,而非 doctor 硬编码 fs.data:
892
+ // 按根上报才能反映用户自定义根(如 host:/、project_x)的真实状态。
893
+ caps.doctor.use.registerCheck({
724
894
  id: 'storage.roots',
725
895
  category: 'filesystem',
726
896
  async run() {
727
897
  const targets = roots.filter(r => r.writable);
728
898
  if (targets.length === 0) {
729
- return { id: 'storage.roots', category: 'filesystem', level: 'warn', message: '没有任何 writable 存储根' };
899
+ return { id: 'storage.roots', category: 'filesystem', level: 'warn', message: t('noWritableRoots') };
730
900
  }
731
901
  const results = [];
732
902
  for (const root of targets) {
@@ -746,15 +916,15 @@ async function probeRootWritable(rootName, dir) {
746
916
  await writeFile(probe, 'ok');
747
917
  await stat(probe);
748
918
  await unlink(probe);
749
- return { id, category: 'filesystem', level: 'ok', message: `根 ${rootName} (${dir}) 可写` };
919
+ return { id, category: 'filesystem', level: 'ok', message: t('rootWritable', { name: rootName, dir }) };
750
920
  }
751
921
  catch (err) {
752
922
  return {
753
923
  id,
754
924
  category: 'filesystem',
755
925
  level: 'error',
756
- message: `根 ${rootName} (${dir}) 不可写`,
757
- detail: err instanceof Error ? err.message : String(err),
926
+ message: t('rootNotWritable', { name: rootName, dir }),
927
+ detail: textOf(err),
758
928
  };
759
929
  }
760
930
  }