@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/README.md +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +334 -164
- package/dist/index.js.map +1 -1
- package/dist/locales/en.d.ts +48 -0
- package/dist/locales/en.d.ts.map +1 -0
- package/dist/locales/en.js +48 -0
- package/dist/locales/en.js.map +1 -0
- package/dist/texts.d.ts +95 -0
- package/dist/texts.d.ts.map +1 -0
- package/dist/texts.js +51 -0
- package/dist/texts.js.map +1 -0
- package/package.json +11 -7
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.
|
|
14
|
-
*
|
|
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
|
|
24
|
-
*
|
|
25
|
-
* 不会让它们出现在文件页里——那些根仅供 agent/工具按 URI 寻址使用。
|
|
27
|
+
* 关于 browsable:仅是给"文件浏览器类 UI"的 hint。WebUI 没有文件浏览页,不读它;
|
|
28
|
+
* 这些根供 agent 与工具按 URI 寻址。
|
|
26
29
|
*
|
|
27
|
-
* 当 agent
|
|
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: '
|
|
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: '
|
|
97
|
+
label: t('nameLabel'),
|
|
103
98
|
required: true,
|
|
104
99
|
onInvalid: 'error',
|
|
105
|
-
description: '
|
|
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: '
|
|
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: '
|
|
114
|
+
description: t('kindDescription'),
|
|
120
115
|
},
|
|
121
116
|
browsable: {
|
|
122
117
|
type: 'boolean',
|
|
123
|
-
label: '
|
|
118
|
+
label: t('browsableLabel'),
|
|
124
119
|
default: false,
|
|
125
120
|
onInvalid: 'error',
|
|
126
|
-
description: '
|
|
121
|
+
description: t('browsableDescription'),
|
|
127
122
|
},
|
|
128
|
-
readable: { type: 'boolean', label: '
|
|
129
|
-
writable: { type: 'boolean', label: '
|
|
130
|
-
deletable: { type: 'boolean', label: '
|
|
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
|
|
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
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
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
|
|
278
|
+
/** 懒解析 checkpoint 服务,在场就调 beforeMutate:写入是否处在回合里、要不要快照由它判断 */
|
|
203
279
|
async snapshot(uri, op, abs, targetUri) {
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
|
|
207
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
341
|
-
//
|
|
342
|
-
|
|
343
|
-
const
|
|
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 (
|
|
347
|
-
await chmod(tmp,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
471
|
+
throw new TextError(t('moveRoot'));
|
|
391
472
|
if (!toRel)
|
|
392
|
-
throw new
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
650
|
+
throw new TextError(t('badPath'));
|
|
563
651
|
const resolved = await realpath(lexical);
|
|
564
652
|
if (!isInside(this.root.realPath, resolved))
|
|
565
|
-
throw new
|
|
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
|
|
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
|
|
664
|
+
throw new TextError(t('badPath'));
|
|
577
665
|
try {
|
|
578
666
|
const target = await lstat(lexical);
|
|
579
667
|
if (target.isSymbolicLink())
|
|
580
|
-
throw new
|
|
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
|
|
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
|
-
|
|
627
|
-
|
|
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
|
-
/**
|
|
642
|
-
async function
|
|
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
|
|
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
|
-
|
|
667
|
-
|
|
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
|
|
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
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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
|
-
|
|
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
|
|
722
|
-
//
|
|
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: '
|
|
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:
|
|
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:
|
|
757
|
-
detail:
|
|
926
|
+
message: t('rootNotWritable', { name: rootName, dir }),
|
|
927
|
+
detail: textOf(err),
|
|
758
928
|
};
|
|
759
929
|
}
|
|
760
930
|
}
|