dsh-m 0.8.5 → 0.9.1

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.
@@ -8,7 +8,7 @@ import { createRequire } from 'node:module';
8
8
  import { isAbsolute, join, normalize } from 'node:path';
9
9
  import { constants as fsConstants, mkdirSync } from 'node:fs';
10
10
  import { lstat, mkdir, open, readFile, readdir, realpath, rename, rm } from 'node:fs/promises';
11
- import { cacheDir } from './env.js';
11
+ import { WEB_PROFILE, cacheRoot } from './env.js';
12
12
  import { decodeUtf8Fatal, fetchJsonLimitedMeta, describeFetchFailure } from './httpx.js';
13
13
  export const CATEGORIES = ['market', 'tools', 'ui', 'search', 'other'];
14
14
  /** 容量上限:2 MiB 是原始 UTF-8 bytes(不是字符数);条目超限拒绝整份清单。 */
@@ -367,14 +367,14 @@ export async function readRegistryFile(path, hooks = {}) {
367
367
  const CACHE_FILE_VERSION = 2;
368
368
  const DEFAULT_CACHE_KEY = 'default';
369
369
  const ACTIVE_SOURCE_FILE = 'active-source.json';
370
- function nsDir(namespace) {
371
- return join(cacheDir(), namespace);
370
+ function nsDir(namespace, profile = WEB_PROFILE) {
371
+ return join(cacheRoot(profile), namespace);
372
372
  }
373
- function cacheFilePath(namespace, cacheKey) {
374
- return join(nsDir(namespace), `${cacheKey}.json`);
373
+ function cacheFilePath(namespace, cacheKey, profile = WEB_PROFILE) {
374
+ return join(nsDir(namespace, profile), `${cacheKey}.json`);
375
375
  }
376
- function cacheDirSyncMode() {
377
- mkdirSync(cacheDir(), { recursive: true, mode: 0o700 });
376
+ function cacheDirSyncMode(profile = WEB_PROFILE) {
377
+ mkdirSync(cacheRoot(profile), { recursive: true, mode: 0o700 });
378
378
  }
379
379
  /** 同 key 写锁:进程内串行化同一 cache 文件的写入。 */
380
380
  const writeLocks = new Map();
@@ -390,11 +390,11 @@ function withKeyLock(key, task) {
390
390
  return next;
391
391
  }
392
392
  /** 原子写:0600 临时文件(O_CREAT|O_EXCL)→ fsync → rename。目标为 symlink 时拒绝。 */
393
- async function atomicWriteJson(target, dir, value) {
393
+ async function atomicWriteJson(target, dir, value, profile = WEB_PROFILE) {
394
394
  return withKeyLock(target, async () => {
395
395
  try {
396
396
  await mkdir(dir, { recursive: true, mode: 0o700 });
397
- cacheDirSyncMode();
397
+ cacheDirSyncMode(profile);
398
398
  let existing = null;
399
399
  try {
400
400
  existing = await lstat(target);
@@ -421,12 +421,12 @@ async function atomicWriteJson(target, dir, value) {
421
421
  }
422
422
  });
423
423
  }
424
- async function writeCacheFile(file) {
425
- return atomicWriteJson(cacheFilePath(file.namespace, file.cacheKey), nsDir(file.namespace), file);
424
+ async function writeCacheFile(file, profile = WEB_PROFILE) {
425
+ return atomicWriteJson(cacheFilePath(file.namespace, file.cacheKey, profile), nsDir(file.namespace, profile), file, profile);
426
426
  }
427
- async function readCacheFile(namespace, cacheKey) {
427
+ async function readCacheFile(namespace, cacheKey, profile = WEB_PROFILE) {
428
428
  try {
429
- const raw = JSON.parse(await readFile(cacheFilePath(namespace, cacheKey), 'utf8'));
429
+ const raw = JSON.parse(await readFile(cacheFilePath(namespace, cacheKey, profile), 'utf8'));
430
430
  if (!raw || typeof raw !== 'object')
431
431
  return null;
432
432
  if (raw.version !== CACHE_FILE_VERSION || raw.namespace !== namespace || raw.cacheKey !== cacheKey)
@@ -449,9 +449,9 @@ function cacheFresh(file, ttlMin) {
449
449
  return Date.now() - t < ttlMin * 60_000;
450
450
  }
451
451
  /** 清理 namespace 内非 default、非当前 custom 的 cache 文件(不动 metadata)。 */
452
- async function pruneCaches(namespace, keepCustomKey) {
452
+ async function pruneCaches(namespace, keepCustomKey, profile = WEB_PROFILE) {
453
453
  try {
454
- const dir = nsDir(namespace);
454
+ const dir = nsDir(namespace, profile);
455
455
  const entries = await readdir(dir).catch(() => []);
456
456
  const keep = new Set([`${DEFAULT_CACHE_KEY}.json`, ACTIVE_SOURCE_FILE]);
457
457
  if (keepCustomKey)
@@ -474,7 +474,7 @@ async function pruneCaches(namespace, keepCustomKey) {
474
474
  * 再 prune 非当前 custom cache。metadata 失败 → 不 prune;prune 失败 → 保留旧 cache;
475
475
  * 都只返回 warning,不反向撤销已成功的 settings update。
476
476
  */
477
- export async function commitActiveSource(address, namespace) {
477
+ export async function commitActiveSource(address, namespace, profile = WEB_PROFILE) {
478
478
  let metadataCommitted = true;
479
479
  if (namespace === 'host') {
480
480
  const meta = {
@@ -484,12 +484,12 @@ export async function commitActiveSource(address, namespace) {
484
484
  cacheKey: address.cacheKey,
485
485
  savedAt: new Date().toISOString(),
486
486
  };
487
- metadataCommitted = await atomicWriteJson(join(nsDir('host'), ACTIVE_SOURCE_FILE), nsDir('host'), meta);
487
+ metadataCommitted = await atomicWriteJson(join(nsDir('host', profile), ACTIVE_SOURCE_FILE), nsDir('host', profile), meta, profile);
488
488
  }
489
489
  if (!metadataCommitted) {
490
490
  return { metadataCommitted: false, pruned: false, warning: 'accepted-source 元数据写入失败,已跳过旧 cache 清理(新配置仍生效)' };
491
491
  }
492
- const pruned = await pruneCaches(namespace, address.kind === 'default' ? null : address.cacheKey);
492
+ const pruned = await pruneCaches(namespace, address.kind === 'default' ? null : address.cacheKey, profile);
493
493
  if (!pruned) {
494
494
  return { metadataCommitted: true, pruned: false, warning: '旧来源 cache 清理失败(不影响新配置生效)' };
495
495
  }
@@ -588,7 +588,7 @@ async function loadDefaultChain(cfg, opts, chain) {
588
588
  source: candidate.source,
589
589
  fetchedAt,
590
590
  registry,
591
- });
591
+ }, chain.profile);
592
592
  return {
593
593
  ...buildState({
594
594
  configuredAddress: '',
@@ -606,7 +606,7 @@ async function loadDefaultChain(cfg, opts, chain) {
606
606
  errors.push(describeFetchFailure({ label: candidate.source, url: candidate.url, err, elapsedMs: Date.now() - startedAt }));
607
607
  }
608
608
  }
609
- const cached = await readCacheFile(chain.namespace, DEFAULT_CACHE_KEY);
609
+ const cached = await readCacheFile(chain.namespace, DEFAULT_CACHE_KEY, chain.profile);
610
610
  if (cached)
611
611
  return loadedFromCacheFile(cached, '', errors);
612
612
  if (chain.includeBundled) {
@@ -656,7 +656,7 @@ async function loadCustomChain(address, cfg, opts, chain) {
656
656
  source,
657
657
  fetchedAt,
658
658
  registry: result.registry,
659
- });
659
+ }, chain.profile);
660
660
  return {
661
661
  ...buildState({
662
662
  configuredAddress: address.normalized,
@@ -674,7 +674,7 @@ async function loadCustomChain(address, cfg, opts, chain) {
674
674
  errors.push(describeFetchFailure({ label: source, url: address.normalized, err, elapsedMs: Date.now() - startedAt }));
675
675
  }
676
676
  if (chain.allowCacheFallback) {
677
- const cached = await readCacheFile(chain.namespace, address.cacheKey);
677
+ const cached = await readCacheFile(chain.namespace, address.cacheKey, chain.profile);
678
678
  if (cached)
679
679
  return loadedFromCacheFile(cached, address.normalized, errors);
680
680
  }
@@ -694,42 +694,45 @@ async function loadCustomChain(address, cfg, opts, chain) {
694
694
  /** active 读取:default/custom 各自 fallback;网络成功后可在本 namespace 内 prune。 */
695
695
  export async function loadRegistry(cfg = {}, opts = {}) {
696
696
  const namespace = opts.namespace ?? 'host';
697
+ const profile = opts.profile ?? WEB_PROFILE;
697
698
  const address = parseRegistryAddress(cfg.registryUrl);
698
699
  const ttlMin = Math.max(0, cfg.cacheTtlMin ?? 60);
699
700
  if (!opts.force) {
700
701
  const cacheKey = address.kind === 'default' ? DEFAULT_CACHE_KEY : address.cacheKey;
701
- const cached = await readCacheFile(namespace, cacheKey);
702
+ const cached = await readCacheFile(namespace, cacheKey, profile);
702
703
  if (cached && cacheFresh(cached, ttlMin))
703
704
  return loadedFromCacheFile(cached, address.normalized, []);
704
705
  }
705
706
  if (address.kind === 'default') {
706
- const loaded = await loadDefaultChain(cfg, opts, { namespace, includeBundled: true });
707
+ const loaded = await loadDefaultChain(cfg, opts, { namespace, profile, includeBundled: true });
707
708
  if (opts.prune !== false && loaded.status === 'ready')
708
- await pruneCaches(namespace, null);
709
+ await pruneCaches(namespace, null, profile);
709
710
  return loaded;
710
711
  }
711
- const loaded = await loadCustomChain(address, cfg, opts, { namespace, allowCacheFallback: true });
712
+ const loaded = await loadCustomChain(address, cfg, opts, { namespace, allowCacheFallback: true, profile });
712
713
  if (opts.prune !== false && loaded.status === 'ready')
713
- await pruneCaches(namespace, address.cacheKey);
714
+ await pruneCaches(namespace, address.cacheKey, profile);
714
715
  return loaded;
715
716
  }
716
717
  /** candidate 读取:apply 前校验专用。只写候选 cache、绝不 prune;default 不落 bundled。 */
717
718
  export async function loadRegistryCandidate(cfg, opts = {}) {
718
719
  const namespace = opts.namespace ?? 'host';
720
+ const profile = opts.profile ?? WEB_PROFILE;
719
721
  const address = parseRegistryAddress(cfg.registryUrl);
720
722
  if (address.kind === 'default') {
721
- return loadDefaultChain(cfg, opts, { namespace, includeBundled: false });
723
+ return loadDefaultChain(cfg, opts, { namespace, profile, includeBundled: false });
722
724
  }
723
- return loadCustomChain(address, cfg, opts, { namespace, allowCacheFallback: true });
725
+ return loadCustomChain(address, cfg, opts, { namespace, allowCacheFallback: true, profile });
724
726
  }
725
727
  /** default 显式加载(下载/主动刷新)。写 default cache,从不 prune。 */
726
728
  export async function loadDefaultRegistry(cfg = {}, opts = {}) {
727
729
  const namespace = opts.namespace ?? 'host';
730
+ const profile = opts.profile ?? WEB_PROFILE;
728
731
  const ttlMin = Math.max(0, cfg.cacheTtlMin ?? 60);
729
732
  if (!opts.force) {
730
- const cached = await readCacheFile(namespace, DEFAULT_CACHE_KEY);
733
+ const cached = await readCacheFile(namespace, DEFAULT_CACHE_KEY, profile);
731
734
  if (cached && cacheFresh(cached, ttlMin))
732
735
  return loadedFromCacheFile(cached, '', []);
733
736
  }
734
- return loadDefaultChain(cfg, opts, { namespace, includeBundled: true });
737
+ return loadDefaultChain(cfg, opts, { namespace, profile, includeBundled: true });
735
738
  }
@@ -32,17 +32,6 @@ function isLoopbackHost(host) {
32
32
  return false;
33
33
  return parsed.hostname === '127.0.0.1' || parsed.hostname === 'localhost' || parsed.hostname === '::1';
34
34
  }
35
- function hostsMatch(originHost, candidate) {
36
- const a = parseHost(originHost);
37
- const b = parseHost(candidate);
38
- if (a === null || b === null)
39
- return false;
40
- if (a.hostname !== b.hostname)
41
- return false;
42
- if (a.port !== '' && b.port !== '' && a.port !== b.port)
43
- return false;
44
- return true;
45
- }
46
35
  export function servingPort(request) {
47
36
  const host = headerString(request.headers.host);
48
37
  if (host === undefined || !isLoopbackHost(host))
@@ -53,25 +42,9 @@ export function servingPort(request) {
53
42
  const port = Number(match[1]);
54
43
  return Number.isInteger(port) && port > 0 && port < 65536 ? port : null;
55
44
  }
56
- export function trustedRestartRequest(request) {
57
- const origin = headerString(request.headers.origin);
58
- if (origin === undefined)
59
- return false;
60
- let from;
61
- try {
62
- const parsed = new URL(origin);
63
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
64
- return false;
65
- from = parsed.host;
66
- }
67
- catch {
68
- return false;
69
- }
70
- const host = headerString(request.headers.host);
71
- const forwardedHost = headerString(request.headers['x-forwarded-host']);
72
- const candidates = [host, forwardedHost].filter((value) => value !== undefined);
73
- return candidates.some((candidate) => hostsMatch(from, candidate));
74
- }
45
+ // 0.9.0 起trustedRestartRequest 已删除:/dshm 入口信任检查改委派官方
46
+ // `connection.requestRejection()`(ADR-0005,语义含 trustedHosts/cross-site/Origin:null),
47
+ // 本模块只剩 servingPort / 重启实现。
75
48
  export function readProcCgroup(readFile = (path, encoding) => readFileSync(path, encoding)) {
76
49
  try {
77
50
  return readFile('/proc/self/cgroup', 'utf8');
package/lib/host.js CHANGED
@@ -4,6 +4,7 @@ import { createApiDispatcher } from './core/host-api.js';
4
4
  import { bindLoaderHost } from './core/live-plugin.js';
5
5
  import { createRegistryController } from './core/registry-controller.js';
6
6
  import { wireRegistrySettings, unwrapConfig } from './core/settings-compat.js';
7
+ import { resolveActiveProfile } from './core/active-profile.js';
7
8
  import { registerTools } from './tools.js';
8
9
  import { appExitFromContext, scheduleRestart } from './core/restart.js';
9
10
  const require = createRequire(import.meta.url);
@@ -30,17 +31,21 @@ export const Config = Schema.object({
30
31
  export function apply(ctx, config) {
31
32
  // 卸载前的 live-disable 依赖 loader(skillhub 同款)
32
33
  bindLoaderHost(ctx);
34
+ // 0.9.0 双 profile(ADR-0005):宿主当前 profile 单一事实源——apply 期解析一次,
35
+ // host 生命周期内不可变;GUI/工具/HTTP/缓存分段全部消费同一对象。
36
+ const profile = resolveActiveProfile(ctx);
37
+ ctx.logger?.info?.('dsh-m: active profile %s (dir=%s, source=%s)', profile.name, profile.dir, profile.source);
33
38
  // registry controller:active config / configured / pending / rejected 分离 + generation fence;
34
39
  // tools 与 Host API 共用同一 active config object(apply 原地更新字段,live 生效)。
35
40
  // unwrapConfig:0.1.7 上 volatile 字段是 cosmokit Volatile 引用,先解包成 plain 值再进 controller。
36
- const controller = createRegistryController(unwrapConfig(config));
41
+ const controller = createRegistryController(unwrapConfig(config), { profile: profile.name });
37
42
  // DSH 0.1.2-rc.1 / 0.1.5-rc.1 / 0.1.7-rc.1 / 0.1.7-rc.2 都经 dsh-cmdline 暴露 appExit;
38
43
  // using it avoids guessing the service unit from release-specific cgroups.
39
44
  const appExit = appExitFromContext(ctx);
40
45
  const restart = (port = null) => scheduleRestart(port, { appExit });
41
46
  // 开关委派的官方服务探测(ADR-0001):运行时按服务存在性探测,不判 DSH 版本号
42
47
  const getService = () => ctx.get?.('pluginManager');
43
- registerTools(ctx, controller.config, { restart });
48
+ registerTools(ctx, controller.config, { restart }, profile);
44
49
  // 设置页接线(双代兼容,详见 core/settings-compat.ts):
45
50
  // - ≤0.1.5:settings.register('dshm', …) scope(get/update/watch);
46
51
  // - 0.1.7-rc.1 / rc.2:Config `.volatile()` 字段自动生成设置页(autoGenerate 默认开,无需 configure),
@@ -67,15 +72,21 @@ export function apply(ctx, config) {
67
72
  ctx.logger?.warn?.(err);
68
73
  }
69
74
  });
70
- // 本地 API:单路由 + method 分发(防护与状态映射在 core/host-api.ts)
75
+ // 本地 API:单路由 + method 分发(信任检查与状态映射在 core/host-api.ts)。
76
+ // 0.9.0(ADR-0005):入口信任检查全量委派官方 connection.requestRejection——
77
+ // Desktop 桥剥 Origin 的请求按官方语义(trustedHosts/loopback/cross-site/Origin:null)判定,
78
+ // 服务缺席或抛错一律 fail-closed 403(warn 一次)。
71
79
  ctx.inject(['webServer'], (c) => {
72
80
  const server = c.webServer;
81
+ const rejectRequest = createRequestRejection(ctx);
73
82
  const handleApi = createApiDispatcher({
74
83
  controller,
75
84
  pkg,
85
+ profile,
76
86
  deps: {
77
87
  scheduleRestart: restart,
78
88
  getService,
89
+ rejectRequest,
79
90
  },
80
91
  });
81
92
  server.register({
@@ -87,3 +98,30 @@ export function apply(ctx, config) {
87
98
  });
88
99
  });
89
100
  }
101
+ /** 守卫式官方 requestRejection 委派:缺席/抛错 fail-closed 403(warn 一次,不刷日志)。 */
102
+ function createRequestRejection(ctx) {
103
+ let warned = false;
104
+ const warnOnce = (err) => {
105
+ if (warned)
106
+ return;
107
+ warned = true;
108
+ ctx.logger?.warn?.('dsh-m: 官方 connection.requestRejection 不可用,/dshm 全入口 fail-closed 403(ADR-0005)');
109
+ if (err)
110
+ ctx.logger?.warn?.(err);
111
+ };
112
+ return (req) => {
113
+ try {
114
+ const connection = ctx.get?.('connection');
115
+ const reject = connection?.requestRejection;
116
+ if (typeof reject !== 'function') {
117
+ warnOnce(undefined);
118
+ return 403;
119
+ }
120
+ return reject.call(connection, { headers: req.headers }) ?? undefined;
121
+ }
122
+ catch (err) {
123
+ warnOnce(err);
124
+ return 403;
125
+ }
126
+ };
127
+ }
package/lib/tools.js CHANGED
@@ -4,6 +4,8 @@ import { installFromRegistry, listInstalledWithMeta, listMarket, uninstallPlugin
4
4
  import { COMMUNITY_CATEGORY_LABELS } from './core/community.js';
5
5
  import { appExitFromContext, scheduleRestart } from './core/restart.js';
6
6
  import { togglePlugin as coreTogglePlugin } from './core/toggle.js';
7
+ import { resolveActiveProfile, assertWriteAllowed } from './core/active-profile.js';
8
+ import { desktopInstallFromRegistry, desktopToggle } from './core/profile-ops.js';
7
9
  export const CATEGORY_LABELS = {
8
10
  market: '市场',
9
11
  tools: '工具',
@@ -55,7 +57,7 @@ function cloneJson(value) {
55
57
  function summaryOf(state) {
56
58
  return { isDefault: state.isDefault, status: state.status, stale: state.stale };
57
59
  }
58
- export function registerTools(ctx, cfg, deps = {}) {
60
+ export function registerTools(ctx, cfg, deps = {}, profile = resolveActiveProfile(ctx)) {
59
61
  const timeoutMs = cfg.timeoutMs ?? 20_000;
60
62
  const m = {
61
63
  listMarket: deps.listMarket ?? listMarket,
@@ -63,6 +65,8 @@ export function registerTools(ctx, cfg, deps = {}) {
63
65
  installFromRegistry: deps.installFromRegistry ?? installFromRegistry,
64
66
  uninstallPlugin: deps.uninstallPlugin ?? uninstallPlugin,
65
67
  upgradePlugin: deps.upgradePlugin ?? upgradePlugin,
68
+ desktopInstall: deps.desktopInstall ?? desktopInstallFromRegistry,
69
+ desktopToggle: deps.desktopToggle ?? desktopToggle,
66
70
  };
67
71
  const restart = deps.restart ?? ((port = null) => scheduleRestart(port, { appExit: appExitFromContext(ctx) }));
68
72
  const toggle = deps.togglePlugin ?? coreTogglePlugin;
@@ -132,6 +136,8 @@ export function registerTools(ctx, cfg, deps = {}) {
132
136
  namespace: 'host',
133
137
  deadlineMs: SEARCH_CORE_DEADLINE_MS,
134
138
  signal: exec?.signal,
139
+ profileDir: profile.dir,
140
+ profile: profile.name,
135
141
  });
136
142
  // 安装标注唯一来源:listMarket 的单次 profile 快照。
137
143
  // 状态不完整且存在可被误标的条目时 fail-closed——不把未知安装状态呈现成未安装;
@@ -173,7 +179,7 @@ export function registerTools(ctx, cfg, deps = {}) {
173
179
  }));
174
180
  ctx.tools.register(defineTool({
175
181
  name: 'dshm_list',
176
- description: 'List plugins installed in the DSH web profile, annotated with 市场安装/非市场安装, sources, and outdated flags. Use when the user asks what plugins are installed or wants to manage local plugins.',
182
+ description: 'List plugins installed in the current DSH profile (web or desktop), annotated with 市场安装/非市场安装, sources, and outdated flags. Use when the user asks what plugins are installed or wants to manage local plugins.',
177
183
  parameters: {},
178
184
  output: {
179
185
  schema: { type: 'object', additionalProperties: true },
@@ -192,6 +198,8 @@ export function registerTools(ctx, cfg, deps = {}) {
192
198
  namespace: 'host',
193
199
  deadlineMs: INSTALLED_CORE_DEADLINE_MS,
194
200
  signal: exec?.signal,
201
+ profileDir: profile.dir,
202
+ profile: profile.name,
195
203
  });
196
204
  return cloneJson({
197
205
  registry: summaryOf(result.registryState),
@@ -239,6 +247,11 @@ export function registerTools(ctx, cfg, deps = {}) {
239
247
  throw new Error('缺少收录 id');
240
248
  const version = typeof args.version === 'string' && args.version.trim() ? args.version.trim() : undefined;
241
249
  const force = args.force === true;
250
+ // 0.9.0:desktop → 官方 pluginManager 委派(新包);未知 profile → 结构化拒绝
251
+ if (profile.kind !== 'web') {
252
+ assertWriteAllowed(profile, 'install');
253
+ return cloneJson(await m.desktopInstall(id, cfg, { version, forceIncompatible: force, namespace: 'host', profile: profile.name }));
254
+ }
242
255
  try {
243
256
  return cloneJson(await m.installFromRegistry(id, cfg, { version, forceIncompatible: force, namespace: 'host' }));
244
257
  }
@@ -280,12 +293,17 @@ export function registerTools(ctx, cfg, deps = {}) {
280
293
  throw new Error('缺少 pkg');
281
294
  if (typeof args.enabled !== 'boolean')
282
295
  throw new Error('缺少 enabled(boolean)');
296
+ // 0.9.0:desktop → 官方管理器委派(服务缺席结构化拒绝);web → 既有委派/降级
297
+ if (profile.kind !== 'web') {
298
+ assertWriteAllowed(profile, 'set-enabled');
299
+ return cloneJson(await m.desktopToggle(target, args.enabled, { getService: getService, profileDir: profile.dir }));
300
+ }
283
301
  return cloneJson(await toggle(target, args.enabled, { getService: getService }));
284
302
  },
285
303
  }));
286
304
  ctx.tools.register(defineTool({
287
305
  name: 'dshm_uninstall',
288
- description: 'Uninstall a DSH plugin from the web profile by package name (pkg from dshm_list). Confirm with the user BEFORE calling. Does not delete plugin data; reports leftover paths instead.',
306
+ description: 'Uninstall a DSH plugin from the current DSH profile by package name (pkg from dshm_list). Confirm with the user BEFORE calling. Does not delete plugin data; reports leftover paths instead.',
289
307
  parameters: {
290
308
  pkg: { type: 'string', required: true, description: '包名 from dshm_list, e.g. dsh-web-search' },
291
309
  },
@@ -305,6 +323,7 @@ export function registerTools(ctx, cfg, deps = {}) {
305
323
  const target = String(args.pkg || '').trim();
306
324
  if (!target)
307
325
  throw new Error('缺少 pkg');
326
+ assertWriteAllowed(profile, 'uninstall');
308
327
  return cloneJson(await m.uninstallPlugin(target, cfg, { namespace: 'host' }));
309
328
  },
310
329
  }));
@@ -338,6 +357,8 @@ export function registerTools(ctx, cfg, deps = {}) {
338
357
  namespace: 'host',
339
358
  deadlineMs: INSTALLED_CORE_DEADLINE_MS,
340
359
  signal: exec?.signal,
360
+ profileDir: profile.dir,
361
+ profile: profile.name,
341
362
  });
342
363
  const items = result.items.map((it) => ({
343
364
  pkg: it.pkg,
@@ -385,6 +406,7 @@ export function registerTools(ctx, cfg, deps = {}) {
385
406
  if (!target)
386
407
  throw new Error('缺少 pkg');
387
408
  const force = args.force === true;
409
+ assertWriteAllowed(profile, 'upgrade');
388
410
  try {
389
411
  return cloneJson(await m.upgradePlugin(target, cfg, { forceIncompatible: force, namespace: 'host' }));
390
412
  }
@@ -416,6 +438,7 @@ export function registerTools(ctx, cfg, deps = {}) {
416
438
  }),
417
439
  timeoutMs: 15_000,
418
440
  async execute() {
441
+ assertWriteAllowed(profile, 'restart');
419
442
  return cloneJson(restart(null));
420
443
  },
421
444
  }));
@@ -431,6 +454,7 @@ export function registerTools(ctx, cfg, deps = {}) {
431
454
  'For installed plugins, call dshm_list / dshm_outdated. Upgrade only after the user confirms which one: dshm_upgrade. Uninstall only after confirmation: dshm_uninstall.',
432
455
  'dshm_toggle switches a plugin on/off: confirm first unless the user already said it in the same message (把 skins 关掉 → execute directly); report live vs restart-required accordingly. ',
433
456
  'dshm_restart only after the user agrees to restart; afterwards tell them to refresh once the page recovers.',
457
+ 'dsh-m operates on the current host profile (web or desktop, per ping.profile): on desktop only read-only market + install-new-package + toggle work; upgrade/uninstall/self-upgrade/restart return structured refusals with guidance — relay them instead of retrying.',
434
458
  ].join(' '),
435
459
  });
436
460
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-m",
3
- "version": "0.8.5",
3
+ "version": "0.9.1",
4
4
  "description": "可自定义收录清单的 DSH 插件市场:收录、安装、卸载、升级一站完成,附 agent 工具与 dshm CLI。",
5
5
  "license": "MIT",
6
6
  "type": "module",