@godv61/dsh-task-engine 0.22.6 → 0.23.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.
@@ -8,6 +8,7 @@
8
8
  *
9
9
  * @module dsh-task-engine/controller
10
10
  */
11
+ import type { ResourceImportRequest, ResourcePreview } from './resource-types.ts';
11
12
  import type { Context } from '@deepseek-ai/cordis';
12
13
  import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
13
14
  import { type StageBinding, type WorkflowConfig } from './engine.ts';
@@ -72,6 +73,28 @@ export interface InstallSkillRequest {
72
73
  /** Absolute workspace directory (required when level is `project`). */
73
74
  path?: string;
74
75
  }
76
+ /** One child directory entry shown by the install directory picker. */
77
+ export interface ListDirsEntry {
78
+ name: string;
79
+ /** True when this directory carries a SKILL.md of its own. */
80
+ hasSkill: boolean;
81
+ }
82
+ /** `listDirs` request: the absolute path to list (empty = the host home directory). */
83
+ export interface ListDirsRequest {
84
+ path: string;
85
+ }
86
+ /** `listDirs` result: the subdirectories of one absolute path for the install picker. */
87
+ export interface ListDirsView {
88
+ ok: boolean;
89
+ /** The absolute directory that was listed (normalized input). */
90
+ path: string;
91
+ entries: ListDirsEntry[];
92
+ /** Filesystem roots offered as jump targets (Windows drive letters, `/` on POSIX). */
93
+ roots: string[];
94
+ /** True when the listed directory itself carries a SKILL.md (installable as-is). */
95
+ currentHasSkill: boolean;
96
+ error?: string;
97
+ }
75
98
  /** Create a project- or user-level rule. */
76
99
  export interface WriteRuleRequest {
77
100
  name: string;
@@ -163,6 +186,10 @@ export interface TaskLedgerItem {
163
186
  }
164
187
  /** One task's ledger projection: identity, stage, and the per-item audit trail. */
165
188
  export interface TaskLedgerEntry {
189
+ risk_level?: string;
190
+ updated_at?: string;
191
+ verification_passed?: boolean;
192
+ review_outcome?: string;
166
193
  task_id: string;
167
194
  title: string;
168
195
  stage: string;
@@ -218,6 +245,20 @@ export interface InitWriteResult {
218
245
  */
219
246
  export default class TaskEngineController extends TypertRemoteService {
220
247
  constructor(ctx: Context);
248
+ private authorizedPath;
249
+ /** Absolute installation roots for a selected workspace. */
250
+ resourceRoots(request: {
251
+ kind: 'skill' | 'rule';
252
+ path: string;
253
+ }): Promise<{
254
+ project: string;
255
+ user: string;
256
+ }>;
257
+ /** Inspect uploaded bytes or a host directory without writing. */
258
+ previewResource(request: ResourceImportRequest): Promise<ResourcePreview>;
259
+ /** Install exactly the reviewed bytes and target, without overwriting. */
260
+ importResource(request: ResourceImportRequest): Promise<ResourcePreview>;
261
+ private resourceImport;
221
262
  /**
222
263
  * Resolve one workspace's effective workflow: a preset flow plus the project's
223
264
  * stage-binding override.
@@ -292,6 +333,14 @@ export default class TaskEngineController extends TypertRemoteService {
292
333
  * @returns the installed path, or `ok: false` with an error.
293
334
  */
294
335
  installSkill(request: InstallSkillRequest): Promise<WriteResourceResult>;
336
+ /**
337
+ * List the direct subdirectories of an absolute path for the install
338
+ * directory picker. Each child is flagged when it carries its own SKILL.md,
339
+ * so the dialog can tell a skill root apart from a plain container or folder.
340
+ * @param request - absolute directory to list (empty = the host home directory).
341
+ * @returns subdirectory names, each with its hasSkill flag.
342
+ */
343
+ listDirs(request: ListDirsRequest): Promise<ListDirsView>;
295
344
  /**
296
345
  * Create a project- or user-level rule by writing its markdown file.
297
346
  * @param request - name, body, and target level.
package/lib/controller.js CHANGED
@@ -42,7 +42,9 @@ var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn,
42
42
  if (target) Object.defineProperty(target, contextIn.name, descriptor);
43
43
  done = true;
44
44
  };
45
- import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
45
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
46
+ import { authorizedWorkspace } from "./workspace-access.js";
47
+ import { prepareImport, commitImport } from "./resource-import.js";
46
48
  import { homedir } from 'node:os';
47
49
  import { dirname, isAbsolute, join } from 'node:path';
48
50
  import { fileURLToPath } from 'node:url';
@@ -100,38 +102,18 @@ function renderSkillFile(name, description, whenToUse, content) {
100
102
  head.push(`whenToUse: ${whenToUse.trim()}`);
101
103
  return `---\n${head.join('\n')}\n---\n\n${content.trimEnd()}\n`;
102
104
  }
103
- /** Directory names never copied when installing a skill directory. */
104
- const SKILL_SKIP_DIRS = new Set(['node_modules', '.git', '__pycache__', '.venv', 'venv', '.idea', '.vscode', '.cache']);
105
- /** Install bounds: refuse oversized bundles instead of copying junk. */
106
- const SKILL_MAX_FILES = 200;
107
- const SKILL_MAX_BYTES = 20 * 1024 * 1024;
108
- /**
109
- * Recursively copy a validated skill directory, skipping dependency caches and
110
- * enforcing file-count / byte caps. Throws with a clear message on oversize.
111
- */
112
- function copySkillDir(source, dest) {
113
- let files = 0;
114
- let bytes = 0;
115
- const walk = (src, dst) => {
116
- mkdirSync(dst, { recursive: true });
117
- for (const entry of readdirSync(src, { withFileTypes: true })) {
118
- if (entry.isDirectory()) {
119
- if (SKILL_SKIP_DIRS.has(entry.name))
120
- continue;
121
- walk(join(src, entry.name), join(dst, entry.name));
122
- }
123
- else if (entry.isFile()) {
124
- const srcPath = join(src, entry.name);
125
- bytes += statSync(srcPath).size;
126
- files += 1;
127
- if (files > SKILL_MAX_FILES || bytes > SKILL_MAX_BYTES) {
128
- throw new Error(`skill 目录过大(上限 ${SKILL_MAX_FILES} 个文件 / ${Math.round(SKILL_MAX_BYTES / 1024 / 1024)} MB),已排除 node_modules/.git 等缓存目录`);
129
- }
130
- copyFileSync(srcPath, join(dst, entry.name));
131
- }
132
- }
133
- };
134
- walk(source, dest);
105
+ /** Dirs hidden from the install directory picker (plain dependency/ignore caches). */
106
+ const SKILL_DIR_FILTER = new Set(['node_modules', '.git', '__pycache__', '.venv', 'venv']);
107
+ /** Filesystem roots the picker offers as jump targets (drive letters on Windows, `/` elsewhere). */
108
+ function filesystemRoots() {
109
+ if (process.platform !== 'win32')
110
+ return ['/'];
111
+ const roots = [];
112
+ for (const letter of 'CDEFGHIJKLMNOPQRSTUVWXYZ') {
113
+ if (existsSync(`${letter}:\\`))
114
+ roots.push(`${letter}:\\`);
115
+ }
116
+ return roots;
135
117
  }
136
118
  /** Scan a markdown directory, returning `{ name }` per `.md` file. */
137
119
  function scanRuleDir(dir, source) {
@@ -210,6 +192,9 @@ function listSkillsFromDir(dir, source) {
210
192
  let TaskEngineController = (() => {
211
193
  let _classSuper = TypertRemoteService;
212
194
  let _instanceExtraInitializers = [];
195
+ let _resourceRoots_decorators;
196
+ let _previewResource_decorators;
197
+ let _importResource_decorators;
213
198
  let _read_decorators;
214
199
  let _write_decorators;
215
200
  let _listSkills_decorators;
@@ -220,6 +205,7 @@ let TaskEngineController = (() => {
220
205
  let _generateInit_decorators;
221
206
  let _writeSkill_decorators;
222
207
  let _installSkill_decorators;
208
+ let _listDirs_decorators;
223
209
  let _writeRule_decorators;
224
210
  let _readSkill_decorators;
225
211
  let _readRule_decorators;
@@ -228,6 +214,9 @@ let TaskEngineController = (() => {
228
214
  return class TaskEngineController extends _classSuper {
229
215
  static {
230
216
  const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
217
+ _resourceRoots_decorators = [Remote];
218
+ _previewResource_decorators = [Remote];
219
+ _importResource_decorators = [Remote];
231
220
  _read_decorators = [Remote];
232
221
  _write_decorators = [Remote];
233
222
  _listSkills_decorators = [Remote];
@@ -238,11 +227,15 @@ let TaskEngineController = (() => {
238
227
  _generateInit_decorators = [Remote];
239
228
  _writeSkill_decorators = [Remote];
240
229
  _installSkill_decorators = [Remote];
230
+ _listDirs_decorators = [Remote];
241
231
  _writeRule_decorators = [Remote];
242
232
  _readSkill_decorators = [Remote];
243
233
  _readRule_decorators = [Remote];
244
234
  _deleteSkill_decorators = [Remote];
245
235
  _deleteRule_decorators = [Remote];
236
+ __esDecorate(this, null, _resourceRoots_decorators, { kind: "method", name: "resourceRoots", static: false, private: false, access: { has: obj => "resourceRoots" in obj, get: obj => obj.resourceRoots }, metadata: _metadata }, null, _instanceExtraInitializers);
237
+ __esDecorate(this, null, _previewResource_decorators, { kind: "method", name: "previewResource", static: false, private: false, access: { has: obj => "previewResource" in obj, get: obj => obj.previewResource }, metadata: _metadata }, null, _instanceExtraInitializers);
238
+ __esDecorate(this, null, _importResource_decorators, { kind: "method", name: "importResource", static: false, private: false, access: { has: obj => "importResource" in obj, get: obj => obj.importResource }, metadata: _metadata }, null, _instanceExtraInitializers);
246
239
  __esDecorate(this, null, _read_decorators, { kind: "method", name: "read", static: false, private: false, access: { has: obj => "read" in obj, get: obj => obj.read }, metadata: _metadata }, null, _instanceExtraInitializers);
247
240
  __esDecorate(this, null, _write_decorators, { kind: "method", name: "write", static: false, private: false, access: { has: obj => "write" in obj, get: obj => obj.write }, metadata: _metadata }, null, _instanceExtraInitializers);
248
241
  __esDecorate(this, null, _listSkills_decorators, { kind: "method", name: "listSkills", static: false, private: false, access: { has: obj => "listSkills" in obj, get: obj => obj.listSkills }, metadata: _metadata }, null, _instanceExtraInitializers);
@@ -253,6 +246,7 @@ let TaskEngineController = (() => {
253
246
  __esDecorate(this, null, _generateInit_decorators, { kind: "method", name: "generateInit", static: false, private: false, access: { has: obj => "generateInit" in obj, get: obj => obj.generateInit }, metadata: _metadata }, null, _instanceExtraInitializers);
254
247
  __esDecorate(this, null, _writeSkill_decorators, { kind: "method", name: "writeSkill", static: false, private: false, access: { has: obj => "writeSkill" in obj, get: obj => obj.writeSkill }, metadata: _metadata }, null, _instanceExtraInitializers);
255
248
  __esDecorate(this, null, _installSkill_decorators, { kind: "method", name: "installSkill", static: false, private: false, access: { has: obj => "installSkill" in obj, get: obj => obj.installSkill }, metadata: _metadata }, null, _instanceExtraInitializers);
249
+ __esDecorate(this, null, _listDirs_decorators, { kind: "method", name: "listDirs", static: false, private: false, access: { has: obj => "listDirs" in obj, get: obj => obj.listDirs }, metadata: _metadata }, null, _instanceExtraInitializers);
256
250
  __esDecorate(this, null, _writeRule_decorators, { kind: "method", name: "writeRule", static: false, private: false, access: { has: obj => "writeRule" in obj, get: obj => obj.writeRule }, metadata: _metadata }, null, _instanceExtraInitializers);
257
251
  __esDecorate(this, null, _readSkill_decorators, { kind: "method", name: "readSkill", static: false, private: false, access: { has: obj => "readSkill" in obj, get: obj => obj.readSkill }, metadata: _metadata }, null, _instanceExtraInitializers);
258
252
  __esDecorate(this, null, _readRule_decorators, { kind: "method", name: "readRule", static: false, private: false, access: { has: obj => "readRule" in obj, get: obj => obj.readRule }, metadata: _metadata }, null, _instanceExtraInitializers);
@@ -264,6 +258,35 @@ let TaskEngineController = (() => {
264
258
  super(ctx, 'taskEngineController', { namespace: 'task-engine' });
265
259
  __runInitializers(this, _instanceExtraInitializers);
266
260
  }
261
+ async authorizedPath(path) {
262
+ return authorizedWorkspace(this.ctx, path, checkedPath);
263
+ }
264
+ /** Absolute installation roots for a selected workspace. */
265
+ async resourceRoots(request) {
266
+ if (!request.path.trim())
267
+ throw new Error('请选择工作区');
268
+ const workspace = await this.authorizedPath(request.path);
269
+ return { project: join(workspace, '.dsh', request.kind + 's'), user: join(dshHome(), request.kind + 's') };
270
+ }
271
+ /** Inspect uploaded bytes or a host directory without writing. */
272
+ async previewResource(request) {
273
+ return this.resourceImport(request, false);
274
+ }
275
+ /** Install exactly the reviewed bytes and target, without overwriting. */
276
+ async importResource(request) {
277
+ return this.resourceImport(request, true);
278
+ }
279
+ async resourceImport(request, commit) {
280
+ try {
281
+ const roots = await this.resourceRoots(request);
282
+ const base = roots[request.level];
283
+ const bundled = request.kind === 'skill' ? bundledSkillExists : bundledRuleExists;
284
+ return commit ? commitImport(request, base, bundled) : prepareImport(request, base, bundled).preview;
285
+ }
286
+ catch (error) {
287
+ return { ok: false, name: '', description: '', content: '', target: '', files: 0, bytes: 0, hash: '', conflict: false, error: error instanceof Error ? error.message : String(error) };
288
+ }
289
+ }
267
290
  /**
268
291
  * Resolve one workspace's effective workflow: a preset flow plus the project's
269
292
  * stage-binding override.
@@ -271,7 +294,7 @@ let TaskEngineController = (() => {
271
294
  * @returns the preset, project, or invalid selection plus its validation problems.
272
295
  */
273
296
  async read(path) {
274
- path = checkedPath(path);
297
+ path = await this.authorizedPath(path);
275
298
  const fs = this.fs();
276
299
  const raw = await readEngText(fs, path);
277
300
  if (raw === undefined) {
@@ -325,7 +348,7 @@ let TaskEngineController = (() => {
325
348
  * `problems` filled when validation fails (nothing is written then).
326
349
  */
327
350
  async write(request) {
328
- request.path = checkedPath(request.path);
351
+ request.path = await this.authorizedPath(request.path);
329
352
  const resolved = resolveFlow(request.flow, request.stage_bindings !== undefined ? { stage_bindings: request.stage_bindings } : undefined);
330
353
  if (!resolved.ok) {
331
354
  return {
@@ -362,7 +385,7 @@ let TaskEngineController = (() => {
362
385
  * @returns the mountable skill catalog.
363
386
  */
364
387
  async listSkills(path) {
365
- path = checkedPath(path);
388
+ path = await this.authorizedPath(path);
366
389
  const project = path === '' ? [] : listSkillsFromDir(join(path, '.dsh/skills'), 'project');
367
390
  const user = listSkillsFromDir(join(dshHome(), 'skills'), 'user');
368
391
  const bundled = listSkillsFromDir(BUNDLED_SKILLS_DIR, 'bundled');
@@ -381,7 +404,7 @@ let TaskEngineController = (() => {
381
404
  * @returns the mountable rule catalog.
382
405
  */
383
406
  async listRules(path) {
384
- path = checkedPath(path);
407
+ path = await this.authorizedPath(path);
385
408
  const project = path === '' ? [] : scanRuleDir(join(path, '.dsh/rules'), 'project');
386
409
  const user = scanRuleDir(join(dshHome(), 'rules'), 'user');
387
410
  const bundled = scanRuleDir(BUNDLED_RULES_DIR, 'bundled');
@@ -405,7 +428,7 @@ let TaskEngineController = (() => {
405
428
  * @returns the ledger entries, empty when no task records exist.
406
429
  */
407
430
  async readTasks(path) {
408
- path = checkedPath(path);
431
+ path = await this.authorizedPath(path);
409
432
  const fs = this.fs();
410
433
  const tasks = [];
411
434
  try {
@@ -422,14 +445,20 @@ let TaskEngineController = (() => {
422
445
  state = JSON.parse(raw);
423
446
  }
424
447
  catch {
425
- continue; // A sibling `.dsh` file that happens to match the prefix but is not a task record.
448
+ throw new Error('任务记录无法解析:' + entry.name);
426
449
  }
450
+ if (!state || ['id', 'title', 'stage', 'branch'].some(key => typeof state[key] !== 'string') || (state.items !== undefined && !Array.isArray(state.items)))
451
+ throw new Error('任务记录格式无效:' + entry.name);
427
452
  tasks.push({
453
+ ...(state.risk_level ? { risk_level: state.risk_level } : {}),
454
+ ...(state.updated_at ? { updated_at: state.updated_at } : {}),
455
+ verification_passed: state.verification?.passed ?? false,
456
+ review_outcome: state.review?.outcome ?? 'pending',
428
457
  task_id: state.id,
429
458
  title: state.title,
430
459
  stage: state.stage,
431
460
  branch: state.branch,
432
- items: state.items.map(item => ({
461
+ items: (state.items ?? []).map(item => ({
433
462
  id: item.id,
434
463
  title: item.title,
435
464
  status: item.status,
@@ -439,8 +468,10 @@ let TaskEngineController = (() => {
439
468
  });
440
469
  }
441
470
  }
442
- catch {
443
- // No listable `.dsh/` directory means there are no task records yet.
471
+ catch (error) {
472
+ // Only an absent directory is an empty ledger; denied access and corruption remain visible.
473
+ if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT'))
474
+ throw error;
444
475
  }
445
476
  return { tasks };
446
477
  }
@@ -450,7 +481,7 @@ let TaskEngineController = (() => {
450
481
  * @returns existence, body, and line count.
451
482
  */
452
483
  async readInit(path) {
453
- path = checkedPath(path);
484
+ path = await this.authorizedPath(path);
454
485
  const { root } = locateInitRoot(path);
455
486
  const raw = await readTextAt(this.fs(), root, INITFILE);
456
487
  if (raw === undefined)
@@ -468,7 +499,7 @@ let TaskEngineController = (() => {
468
499
  * @returns `ok: false` with `lines` over the cap when the body is too long.
469
500
  */
470
501
  async writeInit(request) {
471
- request.path = checkedPath(request.path);
502
+ request.path = await this.authorizedPath(request.path);
472
503
  const lines = lineCount(request.content);
473
504
  if (lines > INIT_MAX_LINES) {
474
505
  return { ok: false, lines, error: `AGENTS.md 为 ${lines} 行,超过上限 ${INIT_MAX_LINES} 行;请精简到项目骨架后重试。` };
@@ -500,7 +531,7 @@ let TaskEngineController = (() => {
500
531
  * @returns the drafted body, or `ok: false` with an error when generation fails.
501
532
  */
502
533
  async generateInit(request) {
503
- request.path = checkedPath(request.path);
534
+ request.path = await this.authorizedPath(request.path);
504
535
  const { root } = locateInitRoot(request.path);
505
536
  const snapshot = scanProject(root);
506
537
  const llm = this.ctx.get('llm');
@@ -551,7 +582,7 @@ let TaskEngineController = (() => {
551
582
  */
552
583
  async writeSkill(request) {
553
584
  if (request.path !== undefined)
554
- request.path = checkedPath(request.path);
585
+ request.path = await this.authorizedPath(request.path);
555
586
  const name = sanitizeName(request.name);
556
587
  if (name === '' || request.description.trim() === '') {
557
588
  return { ok: false, name, path: '', error: 'skill name and description must not be empty' };
@@ -574,45 +605,39 @@ let TaskEngineController = (() => {
574
605
  * @returns the installed path, or `ok: false` with an error.
575
606
  */
576
607
  async installSkill(request) {
577
- const sourceDir = request.sourceDir.trim();
578
- if (sourceDir === '' || !isAbsolute(sourceDir)) {
579
- return { ok: false, name: '', path: '', error: '源目录必须是绝对路径' };
580
- }
581
- const skillFile = join(sourceDir, 'SKILL.md');
582
- if (!existsSync(skillFile)) {
583
- return { ok: false, name: '', path: '', error: `所选目录不是 skill:缺少 ${skillFile}` };
584
- }
585
- let parsed;
586
- try {
587
- parsed = parseSkillFile(readFileSync(skillFile, 'utf8'));
588
- }
589
- catch {
590
- parsed = undefined;
591
- }
592
- if (parsed === undefined) {
593
- return { ok: false, name: '', path: '', error: 'SKILL.md frontmatter 不可读(需要 name + description)' };
594
- }
595
- const name = sanitizeName(parsed.name);
596
- if (name === '') {
597
- return { ok: false, name, path: '', error: 'skill name 无效(只含小写字母数字和 -)' };
598
- }
599
- if (bundledSkillExists(name)) {
600
- return { ok: false, name, path: '', error: `"${name}" 是内置技能,不可覆盖;给 SKILL.md 换个名字` };
601
- }
602
- const base = request.level === 'project'
603
- ? join(request.path !== undefined ? checkedPath(request.path) : '', '.dsh/skills')
604
- : join(dshHome(), 'skills');
605
- const dest = join(base, name);
606
- if (existsSync(dest)) {
607
- return { ok: false, name, path: dest, error: `目标位置已存在同名技能(${dest})——先删除或换名再装` };
608
- }
608
+ const input = { kind: 'skill', level: request.level, path: request.path ?? process.cwd(), files: [], sourceDir: request.sourceDir };
609
+ const preview = await this.previewResource(input);
610
+ if (!preview.ok)
611
+ return { ok: false, name: preview.name, path: preview.target, error: preview.error ?? '安装校验失败' };
612
+ const result = await this.importResource({ ...input, expectedHash: preview.hash });
613
+ return result.ok ? { ok: true, name: result.name, path: result.target } : { ok: false, name: result.name, path: result.target, error: result.error ?? '安装失败' };
614
+ }
615
+ /**
616
+ * List the direct subdirectories of an absolute path for the install
617
+ * directory picker. Each child is flagged when it carries its own SKILL.md,
618
+ * so the dialog can tell a skill root apart from a plain container or folder.
619
+ * @param request - absolute directory to list (empty = the host home directory).
620
+ * @returns subdirectory names, each with its hasSkill flag.
621
+ */
622
+ async listDirs(request) {
623
+ const raw = request.path.trim();
624
+ const dir = raw === '' ? homedir() : raw;
625
+ const roots = filesystemRoots();
626
+ if (!isAbsolute(dir)) {
627
+ return { ok: false, path: dir, entries: [], roots, currentHasSkill: false, error: '路径必须是绝对路径' };
628
+ }
629
+ let entries;
609
630
  try {
610
- copySkillDir(sourceDir, dest);
631
+ entries = readdirSync(dir, { withFileTypes: true });
611
632
  }
612
633
  catch (error) {
613
- return { ok: false, name, path: dest, error: error instanceof Error ? error.message : String(error) };
634
+ return { ok: false, path: dir, entries: [], roots, currentHasSkill: false, error: error instanceof Error ? error.message : String(error) };
614
635
  }
615
- return { ok: true, name, path: dest };
636
+ const list = entries
637
+ .filter(entry => entry.isDirectory() && !SKILL_DIR_FILTER.has(entry.name))
638
+ .sort((a, b) => a.name.localeCompare(b.name))
639
+ .map(entry => ({ name: entry.name, hasSkill: existsSync(join(dir, entry.name, 'SKILL.md')) }));
640
+ return { ok: true, path: dir, entries: list, roots, currentHasSkill: existsSync(join(dir, 'SKILL.md')) };
616
641
  }
617
642
  /**
618
643
  * Create a project- or user-level rule by writing its markdown file.
@@ -621,7 +646,7 @@ let TaskEngineController = (() => {
621
646
  */
622
647
  async writeRule(request) {
623
648
  if (request.path !== undefined)
624
- request.path = checkedPath(request.path);
649
+ request.path = await this.authorizedPath(request.path);
625
650
  const name = sanitizeName(request.name);
626
651
  if (name === '' || request.content.trim() === '') {
627
652
  return { ok: false, name, path: '', error: 'rule name and content must not be empty' };
@@ -643,7 +668,7 @@ let TaskEngineController = (() => {
643
668
  */
644
669
  async readSkill(request) {
645
670
  if (request.path !== undefined)
646
- request.path = checkedPath(request.path);
671
+ request.path = await this.authorizedPath(request.path);
647
672
  const name = sanitizeName(request.name);
648
673
  const base = request.level === 'bundled'
649
674
  ? BUNDLED_SKILLS_DIR
@@ -668,7 +693,7 @@ let TaskEngineController = (() => {
668
693
  */
669
694
  async readRule(request) {
670
695
  if (request.path !== undefined)
671
- request.path = checkedPath(request.path);
696
+ request.path = await this.authorizedPath(request.path);
672
697
  const name = sanitizeName(request.name);
673
698
  const base = request.level === 'bundled'
674
699
  ? BUNDLED_RULES_DIR
@@ -690,7 +715,7 @@ let TaskEngineController = (() => {
690
715
  */
691
716
  async deleteSkill(request) {
692
717
  if (request.path !== undefined)
693
- request.path = checkedPath(request.path);
718
+ request.path = await this.authorizedPath(request.path);
694
719
  const name = sanitizeName(request.name);
695
720
  if (name === '')
696
721
  return { ok: false, name, path: '', error: 'skill name must not be empty' };
@@ -707,7 +732,7 @@ let TaskEngineController = (() => {
707
732
  */
708
733
  async deleteRule(request) {
709
734
  if (request.path !== undefined)
710
- request.path = checkedPath(request.path);
735
+ request.path = await this.authorizedPath(request.path);
711
736
  const name = sanitizeName(request.name);
712
737
  if (name === '')
713
738
  return { ok: false, name, path: '', error: 'rule name must not be empty' };
@@ -802,7 +827,8 @@ class WorkspaceRegistry {
802
827
  }
803
828
  }
804
829
  function normalizeKey(path) {
805
- return path.replace(/[\\/]+$/u, '').toLowerCase();
830
+ const key = path.replace(/\\/gu, '/').replace(/\/+$/u, '');
831
+ return process.platform === 'win32' ? key.toLowerCase() : key;
806
832
  }
807
833
  const workspaceRegistry = new WorkspaceRegistry();
808
834
  /** When true, an empty registry no longer falls back to checkedPath — Remote calls fail closed until a host registers workspaces. */
@@ -825,13 +851,16 @@ export function enableStrictWorkspaces() {
825
851
  */
826
852
  /** Exported for tests and host integrations: the Remote path guard itself. */
827
853
  export function checkedPath(raw) {
828
- if (raw.trim() === '')
829
- return raw; // empty means the host default, preserved for compatibility
854
+ if (raw.trim() === '') {
855
+ if (strictWorkspaces)
856
+ throw new Error('workspace path must be absolute');
857
+ return raw;
858
+ } // empty means the host default, preserved for compatibility
830
859
  if (!isAbsolute(raw)) {
831
860
  throw new RemoteError('gateway/internal', `task-engine: workspace path must be absolute: ${raw}`, {});
832
861
  }
833
862
  const normalized = raw.replace(/[\\/]+$/u, '');
834
- const lower = normalized.toLowerCase();
863
+ const lower = normalized.replace(/\\/gu, '/').toLowerCase();
835
864
  const forbidden = ['c:/windows', 'c:/program files', 'c:/program files (x86)', 'c:/users'];
836
865
  for (const prefix of forbidden) {
837
866
  if (lower === prefix || lower.startsWith(`${prefix}/`)) {