dsh-sidebar-row-menu 1.9.2 → 1.10.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.
@@ -0,0 +1,190 @@
1
+ /**
2
+ * 按工作区记住「新会话的默认权限」。
3
+ *
4
+ * DSH 原生只有一份全局默认(设置里的 permission.defaultPreset),没有
5
+ * 「这个工作区新建会话用完全访问、那个工作区用只读」的概念。这里把
6
+ * workspaceId → preset 存成插件自己的小 JSON,再在 agent/created 时把
7
+ * 它写成该会话的 permission/preset 事件:
8
+ *
9
+ * - 事件是宿主自己的格式,会话内 `/permission` 看到的状态完全一致;
10
+ * - 不碰全局设置,也不影响已有会话;
11
+ * - 原生 `pinInitialPermission` 只在会话还没有 preset 时写入全局默认,
12
+ * 我们已经写过时会保留我们的值(顺序反过来也会被后写的覆盖成同一结果)。
13
+ *
14
+ * 文件位置:$DSH_HOME/storages/dsh-sidebar-row-menu/permission-defaults.json
15
+ */
16
+
17
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
18
+ import { homedir } from 'node:os';
19
+ import { dirname, join } from 'node:path';
20
+
21
+ const STORAGE_VERSION = 1;
22
+ const STORAGE_DIR = 'storages';
23
+ const STORAGE_OWNER = 'dsh-sidebar-row-menu';
24
+ const STORAGE_FILE = 'permission-defaults.json';
25
+
26
+ /** 解析 DSH 数据目录;桌面端会设置 DSH_HOME,命令行 fallback 到 ~/.dsh。 */
27
+ export function resolveDshHome(env = process.env, home = homedir()) {
28
+ const configured = typeof env?.DSH_HOME === 'string' ? env.DSH_HOME.trim() : '';
29
+ return configured === '' ? join(home, '.dsh') : configured;
30
+ }
31
+
32
+ /** 权限默认值文件的绝对路径。 */
33
+ export function permissionDefaultsPath(env = process.env, home = homedir()) {
34
+ return join(resolveDshHome(env, home), STORAGE_DIR, STORAGE_OWNER, STORAGE_FILE);
35
+ }
36
+
37
+ function emptyDefaults() {
38
+ return {};
39
+ }
40
+
41
+ /** 只接受「非空字符串 → 非空 preset 名」,坏数据直接丢弃。 */
42
+ function sanitizeDefaults(value) {
43
+ const result = emptyDefaults();
44
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return result;
45
+ for (const [workspaceId, preset] of Object.entries(value)) {
46
+ if (typeof workspaceId !== 'string' || workspaceId === '') continue;
47
+ if (typeof preset !== 'string' || preset.trim() === '') continue;
48
+ result[workspaceId] = preset;
49
+ }
50
+ return result;
51
+ }
52
+
53
+ /** 读文件;不存在/损坏都当作空表(下一次写入会重建)。 */
54
+ export function readPermissionDefaults(filePath = permissionDefaultsPath()) {
55
+ try {
56
+ const text = readFileSync(filePath, 'utf8');
57
+ const parsed = JSON.parse(text);
58
+ return sanitizeDefaults(parsed?.defaults);
59
+ } catch {
60
+ return emptyDefaults();
61
+ }
62
+ }
63
+
64
+ /**
65
+ * 文件存储。读失败静默回退空表,写失败抛给调用方决定是否提示。
66
+ * 写入用 tmp + rename,避免中途崩溃留下半截 JSON。
67
+ */
68
+ export function createPermissionDefaultsStore(filePath = permissionDefaultsPath()) {
69
+ return {
70
+ filePath,
71
+ read() {
72
+ return readPermissionDefaults(filePath);
73
+ },
74
+ /** 传 null 表示删除该工作区的覆盖,恢复跟随全局默认。 */
75
+ set(workspaceId, preset) {
76
+ if (typeof workspaceId !== 'string' || workspaceId === '') {
77
+ throw new TypeError('workspaceId must be a non-empty string');
78
+ }
79
+ const defaults = readPermissionDefaults(filePath);
80
+ if (preset === null || preset === undefined) {
81
+ delete defaults[workspaceId];
82
+ } else {
83
+ if (typeof preset !== 'string' || preset.trim() === '') {
84
+ throw new TypeError('preset must be a non-empty string or null');
85
+ }
86
+ defaults[workspaceId] = preset;
87
+ }
88
+ mkdirSync(dirname(filePath), { recursive: true });
89
+ const temporary = `${filePath}.tmp`;
90
+ writeFileSync(temporary, `${JSON.stringify({ version: STORAGE_VERSION, defaults }, null, 2)}\n`, 'utf8');
91
+ renameSync(temporary, filePath);
92
+ return defaults;
93
+ },
94
+ };
95
+ }
96
+
97
+ /** 路径比较用的规范化:统一分隔符、去掉尾部分隔符。 */
98
+ function normalizePath(value) {
99
+ if (typeof value !== 'string') return '';
100
+ let result = value.trim().replace(/[\\/]+/g, '/');
101
+ while (result.length > 1 && result.endsWith('/')) result = result.slice(0, -1);
102
+ return result;
103
+ }
104
+
105
+ /**
106
+ * 会话 cwd → 工作区 id。会话头里的 cwd 和工作区登记的 path 都是规范化
107
+ * 绝对路径;Windows 上再加一层大小写不敏感兜底。
108
+ */
109
+ export function workspaceIdForCwd(registry, cwd) {
110
+ const target = normalizePath(cwd);
111
+ if (target === '' || registry === null || registry === undefined) return '';
112
+ let workspaces;
113
+ try {
114
+ workspaces = typeof registry.list === 'function' ? registry.list() : [];
115
+ } catch {
116
+ return '';
117
+ }
118
+ if (!Array.isArray(workspaces)) return '';
119
+ const entries = workspaces.filter((entry) => entry !== null && typeof entry === 'object');
120
+ const exact = entries.find((entry) => normalizePath(entry.path) === target);
121
+ if (exact !== undefined && typeof exact.id === 'string') return exact.id;
122
+ if (process.platform !== 'win32') return '';
123
+ const folded = entries.find((entry) => normalizePath(entry.path).toLowerCase() === target.toLowerCase());
124
+ return folded !== undefined && typeof folded.id === 'string' ? folded.id : '';
125
+ }
126
+
127
+ function presetNamesOf(service) {
128
+ try {
129
+ const names = service?.names;
130
+ return Array.isArray(names) ? names : null;
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * 在 agent/created 上把工作区默认写进新会话。
138
+ * 只在真正新建的交互会话上动手:
139
+ * - source 必须是 startup(resume / clear / compact 不碰);
140
+ * - 子代理和 seeded 会话(fork、压缩等)不碰;
141
+ * - 找不到工作区或 preset 已被删除时静默跳过。
142
+ */
143
+ export function applyWorkspacePermission(ctx, payload, store) {
144
+ if (payload?.source !== 'startup') return false;
145
+ const session = payload?.agent?.session;
146
+ if (session === null || session === undefined) return false;
147
+ const header = session.header;
148
+ if (header === null || header === undefined) return false;
149
+ if (header.origin === 'subagent' || header.isSeeded === true) return false;
150
+
151
+ let registry;
152
+ try {
153
+ registry = ctx.get?.('workspaceRegistry');
154
+ } catch {
155
+ return false;
156
+ }
157
+ const workspaceId = workspaceIdForCwd(registry, header.cwd);
158
+ if (workspaceId === '') return false;
159
+
160
+ const defaults = store.read();
161
+ const preset = defaults[workspaceId];
162
+ if (typeof preset !== 'string' || preset === '') return false;
163
+
164
+ let service;
165
+ try {
166
+ service = ctx.get?.('permissionPresets');
167
+ } catch {
168
+ return false;
169
+ }
170
+ if (service === null || service === undefined || typeof service.set !== 'function') return false;
171
+ const names = presetNamesOf(service);
172
+ if (names !== null && !names.includes(preset)) return false;
173
+ service.set(session, preset);
174
+ return true;
175
+ }
176
+
177
+ /**
178
+ * 注册监听。prepend 让本监听排在原生 `pinInitialPermission` 之前,
179
+ * 这样新会话的日志里只有一条 preset 事件;即便顺序反过来,后写的
180
+ * `set()` 也会把状态纠正成工作区默认,结果一致。
181
+ */
182
+ export function registerWorkspacePermissionDefaults(ctx, store) {
183
+ return ctx.on('agent/created', (payload) => {
184
+ try {
185
+ applyWorkspacePermission(ctx, payload, store);
186
+ } catch (error) {
187
+ ctx.logger?.warn?.('[dsh-sidebar-row-menu] workspace permission default failed:', error);
188
+ }
189
+ }, { prepend: true });
190
+ }
package/lib/host/route.js CHANGED
@@ -10,6 +10,7 @@
10
10
  import { inspectOpenPath } from './paths.js';
11
11
  import { isLoopbackRequest } from './security.js';
12
12
  import { openInVscode, revealDirectory } from './launch.js';
13
+ import { createPermissionDefaultsStore } from './permission-defaults.js';
13
14
  import { DELETE_BATCH_LIMIT, removeSessions } from './session-delete.js';
14
15
  import { removeWorkspaceWithSessions } from './workspace-delete.js';
15
16
 
@@ -18,9 +19,26 @@ export const ROUTE_PREFIX = '/dsh-sidebar-row-menu';
18
19
  export const ROUTE_OPEN = `${ROUTE_PREFIX}/open`;
19
20
  export const ROUTE_DELETE = `${ROUTE_PREFIX}/session/delete`;
20
21
  export const ROUTE_WORKSPACE_DELETE = `${ROUTE_PREFIX}/workspace/delete`;
22
+ export const ROUTE_PERMISSION_DEFAULTS = `${ROUTE_PREFIX}/permission-defaults`;
21
23
  /** 请求体上限(只传路径或会话 id 列表,64KB 足够)。 */
22
24
  const MAX_BODY_BYTES = 64 * 1024;
23
25
 
26
+ /** 读原生权限目录;旧宿主没有该服务时返回 null。 */
27
+ function readPermissionCatalog(ctx) {
28
+ try {
29
+ const service = ctx?.get?.('permissionPresets');
30
+ if (service === null || service === undefined || typeof service.catalog !== 'function') return null;
31
+ const catalog = service.catalog();
32
+ if (catalog === null || typeof catalog !== 'object' || catalog.then !== undefined) return null;
33
+ return {
34
+ options: Array.isArray(catalog.options) ? catalog.options : [],
35
+ defaultPreset: typeof catalog.defaultPreset === 'string' ? catalog.defaultPreset : '',
36
+ };
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
24
42
  /** 写一个 JSON 响应;handler 自己拥有响应生命周期。 */
25
43
  function writeJson(res, status, body) {
26
44
  const text = JSON.stringify(body);
@@ -232,7 +250,91 @@ export async function handleWorkspaceDeleteRequest(request, response, ctx) {
232
250
  }
233
251
  }
234
252
 
235
- /** 构造 webServer 路由对象(kind=prefix)。 */export function createOpenRoute(ctx) {
253
+ /**
254
+ * 读写「工作区 → 新会话默认权限」映射。
255
+ * GET /dsh-sidebar-row-menu/permission-defaults → { ok, defaults }
256
+ * POST /dsh-sidebar-row-menu/permission-defaults { workspaceId, preset }
257
+ * preset=null 表示删除覆盖、恢复跟随全局默认。
258
+ * 写之前用宿主 permissionPresets.names 校验,避免存进一个不存在的预设。
259
+ */
260
+ export async function handlePermissionDefaultsRequest(request, response, ctx, store) {
261
+ if (!isLoopbackRequest(request)) {
262
+ writeJson(response, 403, { ok: false, error: 'forbidden' });
263
+ return;
264
+ }
265
+ if (pathnameOf(request) !== ROUTE_PERMISSION_DEFAULTS) {
266
+ writeJson(response, 404, { ok: false, error: 'not-found' });
267
+ return;
268
+ }
269
+ if (store === null || store === undefined || typeof store.read !== 'function') {
270
+ writeJson(response, 503, { ok: false, error: 'service-unavailable' });
271
+ return;
272
+ }
273
+ if (request.method === 'GET') {
274
+ writeJson(response, 200, {
275
+ ok: true,
276
+ defaults: store.read(),
277
+ catalog: readPermissionCatalog(ctx),
278
+ });
279
+ return;
280
+ }
281
+ if (request.method !== 'POST') {
282
+ writeJson(response, 405, { ok: false, error: 'method-not-allowed' });
283
+ return;
284
+ }
285
+
286
+ let payload;
287
+ try {
288
+ payload = await readJsonBody(request);
289
+ } catch {
290
+ writeJson(response, 400, { ok: false, error: 'bad-request' });
291
+ return;
292
+ }
293
+ const workspaceId = payload?.workspaceId;
294
+ if (typeof workspaceId !== 'string' || workspaceId.trim() === '') {
295
+ writeJson(response, 400, { ok: false, error: 'bad-request' });
296
+ return;
297
+ }
298
+ const preset = payload?.preset;
299
+ if (preset !== null && (typeof preset !== 'string' || preset.trim() === '')) {
300
+ writeJson(response, 400, { ok: false, error: 'bad-request' });
301
+ return;
302
+ }
303
+ if (preset !== null) {
304
+ let service;
305
+ try {
306
+ service = ctx?.get?.('permissionPresets');
307
+ } catch {
308
+ service = undefined;
309
+ }
310
+ let names = null;
311
+ try {
312
+ names = Array.isArray(service?.names) ? service.names : null;
313
+ } catch {
314
+ names = null;
315
+ }
316
+ if (names === null) {
317
+ writeJson(response, 503, { ok: false, error: 'service-unavailable' });
318
+ return;
319
+ }
320
+ if (!names.includes(preset)) {
321
+ writeJson(response, 400, { ok: false, error: 'bad-preset' });
322
+ return;
323
+ }
324
+ }
325
+
326
+ try {
327
+ const defaults = store.set(workspaceId, preset);
328
+ writeJson(response, 200, { ok: true, defaults });
329
+ } catch (error) {
330
+ ctx?.logger?.warn?.('[dsh-sidebar-row-menu] permission defaults write failed:', error);
331
+ writeJson(response, 500, { ok: false, error: 'write-failed' });
332
+ }
333
+ }
334
+
335
+ /** 构造 webServer 路由对象(kind=prefix)。 */
336
+ export function createOpenRoute(ctx, options = {}) {
337
+ const permissionStore = options.permissionStore ?? createPermissionDefaultsStore();
236
338
  return {
237
339
  kind: 'prefix',
238
340
  path: ROUTE_PREFIX,
@@ -241,6 +343,9 @@ export async function handleWorkspaceDeleteRequest(request, response, ctx) {
241
343
  if (pathname === ROUTE_OPEN) return handleOpenRequest(request, response);
242
344
  if (pathname === ROUTE_DELETE) return handleSessionDeleteRequest(request, response, ctx);
243
345
  if (pathname === ROUTE_WORKSPACE_DELETE) return handleWorkspaceDeleteRequest(request, response, ctx);
346
+ if (pathname === ROUTE_PERMISSION_DEFAULTS) {
347
+ return handlePermissionDefaultsRequest(request, response, ctx, permissionStore);
348
+ }
244
349
  if (!isLoopbackRequest(request)) {
245
350
  writeJson(response, 403, { ok: false, error: 'forbidden' });
246
351
  return undefined;
package/lib/index.js CHANGED
@@ -24,6 +24,10 @@
24
24
  */
25
25
 
26
26
  import { createOpenRoute } from './host/route.js';
27
+ import {
28
+ createPermissionDefaultsStore,
29
+ registerWorkspacePermissionDefaults,
30
+ } from './host/permission-defaults.js';
27
31
 
28
32
  export const name = 'sidebar-row-menu';
29
33
  /** cordis 依赖:网页服务器 + 删除会话时需要读写的宿主服务。 */
@@ -32,16 +36,37 @@ export const inject = ['webServer', 'sessions', 'sessionPersistence', 'sessionQu
32
36
  export { isLoopbackRequest } from './host/security.js';
33
37
  export { inspectOpenPath, toVscodeFileUrl } from './host/paths.js';
34
38
  export { resolveVscodeLauncher } from './host/launch.js';
35
- export { createOpenRoute, handleSessionDeleteRequest, handleWorkspaceDeleteRequest } from './host/route.js';
39
+ export {
40
+ createOpenRoute,
41
+ handlePermissionDefaultsRequest,
42
+ handleSessionDeleteRequest,
43
+ handleWorkspaceDeleteRequest,
44
+ } from './host/route.js';
45
+ export {
46
+ applyWorkspacePermission,
47
+ createPermissionDefaultsStore,
48
+ permissionDefaultsPath,
49
+ readPermissionDefaults,
50
+ registerWorkspacePermissionDefaults,
51
+ resolveDshHome,
52
+ workspaceIdForCwd,
53
+ } from './host/permission-defaults.js';
36
54
  export { DELETE_BATCH_LIMIT, removeSession, removeSessions } from './host/session-delete.js';
37
55
  export { removeWorkspaceWithSessions } from './host/workspace-delete.js';
38
56
 
39
57
  /**
40
- * 注册路由。
58
+ * 注册路由与新会话默认权限。
41
59
  * @param ctx - 宿主插件上下文(带 webServer 与会话服务)。
42
60
  */
43
61
  export function apply(ctx) {
44
- const route = createOpenRoute(ctx);
62
+ const permissionStore = createPermissionDefaultsStore();
63
+ const route = createOpenRoute(ctx, { permissionStore });
64
+ try {
65
+ registerWorkspacePermissionDefaults(ctx, permissionStore);
66
+ } catch (error) {
67
+ // 老宿主没有 agent/created 事件时只是没有按工作区的默认权限,不能拖垮插件。
68
+ ctx.logger?.warn?.('[dsh-sidebar-row-menu] permission listener registration failed:', error);
69
+ }
45
70
  try {
46
71
  ctx.effect(() => ctx.webServer.register(route), 'dsh-sidebar-row-menu: routes');
47
72
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-sidebar-row-menu",
3
- "version": "1.9.2",
3
+ "version": "1.10.0",
4
4
  "type": "module",
5
5
  "description": "Adds open-with and cleanup entries to the DSH sidebar context menus (interleaved on 0.2.2+, self-drawn workspace menu on 0.2.1), plus a conversation-body right-click menu that mirrors the session header toolbar with in-menu open-with and more-actions submenus.",
6
6
  "keywords": [
@@ -61,7 +61,7 @@
61
61
  "scripts": {
62
62
  "test": "node --test test/host.test.mjs test/manifest.test.mjs test/client.test.mjs",
63
63
  "test:local": "node --test --test-isolation=none test/host.test.mjs test/manifest.test.mjs test/client.test.mjs",
64
- "check": "node --check lib/index.js && node --check lib/client.js && node --check lib/host/paths.js && node --check lib/host/security.js && node --check lib/host/launch.js && node --check lib/host/route.js && node --check lib/host/session-delete.js && node --check lib/host/workspace-delete.js",
64
+ "check": "node --check lib/index.js && node --check lib/client.js && node --check lib/host/paths.js && node --check lib/host/security.js && node --check lib/host/launch.js && node --check lib/host/route.js && node --check lib/host/permission-defaults.js && node --check lib/host/session-delete.js && node --check lib/host/workspace-delete.js",
65
65
  "build": "npm pack",
66
66
  "marketplace:check": "node scripts/marketplace-preflight.mjs",
67
67
  "prepack": "npm run check",