@godv61/dsh-task-engine 0.22.7 → 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.
package/.p0-test.mjs CHANGED
@@ -19,6 +19,7 @@ function assert(cond, msg) {
19
19
  throw new Error(msg)
20
20
  }
21
21
  passed++
22
+ if (process.env.DSH_TEST_DETAILS === '1') console.log(`PASS ${passed}: ${msg}`)
22
23
  }
23
24
  async function assertThrows(fn, fragment, msg) {
24
25
  try {
@@ -708,41 +709,26 @@ function memProbe(files) {
708
709
  assert(ctl.checkedPath('D:/proj/one') === 'D:/proj/one', 'strict mode still admits registered workspaces')
709
710
  }
710
711
 
711
- // ── 36. 0.22.5: directory-bundle skill install Remote ──────────────────────
712
- {
713
- const controller = readFileSync('./lib/controller.js', 'utf8')
714
- assert(controller.includes('installSkill'), 'installSkill Remote is registered')
715
- assert(controller.includes('源目录必须是绝对路径'), 'install rejects a non-absolute source directory')
716
- assert(controller.includes('SKILL.md'), 'install requires SKILL.md in the source directory')
717
- assert(controller.includes('node_modules'), 'install skips dependency caches')
718
- assert(controller.includes('内置技能,不可覆盖'), 'install refuses to shadow a bundled skill')
719
- }
720
- {
721
- const client = readFileSync('./lib/client.js', 'utf8')
722
- assert(client.includes('installSkill'), 'client declares the installSkill Remote contract')
723
- assert(client.includes('sourceDir'), 'client schema carries the source directory')
724
- }
725
712
 
726
- // ── 37. 0.22.7: installSkill auto-locates a single nested skill root ───────
713
+ // A second writer between content read and write must not be silently overwritten.
727
714
  {
728
- const controller = readFileSync('./lib/controller.js', 'utf8')
729
- assert(controller.includes('多个带 SKILL.md 的子目录') || controller.includes("candidates.length > 1"),
730
- 'installSkill rejects an ambiguous container directory')
731
- assert(controller.includes('缺少') && controller.includes('candidates[0]'),
732
- 'installSkill auto-locates the single nested skill root')
733
- }
734
-
735
- // ── 38. 0.22.7: install directory picker (listDirs) + package-skill bounds ──
736
- {
737
- const controller = readFileSync('./lib/controller.js', 'utf8')
738
- assert(controller.includes('async listDirs'), 'listDirs Remote serves the install directory picker')
739
- assert(controller.includes('SKILL_DIR_FILTER'), 'picker filters dependency caches from the listing')
740
- assert(controller.includes('SKILL_MAX_FILES = 1000'), 'package-skill file bound is generous')
741
- assert(controller.includes('SKILL_MAX_BYTES = 100'), 'package-skill byte bound is generous')
742
- }
743
- {
744
- const client = readFileSync('./lib/client.js', 'utf8')
745
- assert(client.includes('listDirs'), 'client declares the listDirs Remote contract')
715
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
716
+ const exe = await registered(fs)
717
+ await exe({ operation: 'create', task_id: 'CAS-RACE', title: 'race', branch: 'main' }, EXEC)
718
+ const originalRead = fs.readText.bind(fs)
719
+ let taskReads = 0
720
+ fs.readText = async target => {
721
+ const value = await originalRead(target)
722
+ if (target.targetKey === '.dsh/task-CAS-RACE.json' && ++taskReads === 2) {
723
+ const other = JSON.parse(value)
724
+ other.title = 'concurrent writer'
725
+ other.revision++
726
+ await fs.writeText(target, JSON.stringify(other))
727
+ }
728
+ return value
729
+ }
730
+ await assertThrows(() => exe({ operation: 'scope', task_id: 'CAS-RACE', files: ['a'] }, EXEC), 'changed concurrently', 'CAS rejects writes when version changes after the content read')
731
+ assert(JSON.parse(fs._files.get('.dsh/task-CAS-RACE.json')).title === 'concurrent writer', 'CAS preserves the concurrent writer record')
746
732
  }
747
733
 
748
734
  console.log(`\nP0 acceptance: ${passed} checks passed`)
@@ -0,0 +1,133 @@
1
+ import test from 'node:test'
2
+ import fs from 'node:fs'
3
+ import { syncBuiltinESMExports } from 'node:module'
4
+ import assert from 'node:assert/strict'
5
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, symlinkSync, readdirSync } from 'node:fs'
6
+ import { tmpdir } from 'node:os'
7
+ import { join, resolve, sep } from 'node:path'
8
+ import { prepareImport, commitImport } from './lib/resource-import.js'
9
+
10
+ const file = (path, text) => ({path,base64:Buffer.from(text).toString('base64')})
11
+ const skill = '---\nname: sample-skill\ndescription: A test skill\n---\n# Sample\n'
12
+ const baseRequest = () => ({kind:'skill',level:'project',path:'unused',files:[file('sample/SKILL.md',skill),file('sample/assets/data.bin',Buffer.from([0,128,255]))]})
13
+ function fixture(t) {
14
+ const root=mkdtempSync(join(tmpdir(),'task-engine-import-'))
15
+ t.after(()=> { const full=resolve(root); assert.ok(full.startsWith(resolve(tmpdir())+sep)); rmSync(full,{recursive:true,force:true}) })
16
+ return {root,base:join(root,'skills')}
17
+ }
18
+ test('preview has no filesystem side effects and reports exact bytes',t=>{
19
+ const {base}=fixture(t),r=baseRequest(),p=prepareImport(r,base,()=>false).preview
20
+ assert.equal(p.name,'sample-skill');assert.equal(p.files,2);assert.equal(p.bytes,Buffer.byteLength(skill)+3);assert.equal(existsSync(base),false)
21
+ })
22
+ test('confirmed install preserves binary assets and refuses duplicates',t=>{
23
+ const {base}=fixture(t),r=baseRequest(),p=prepareImport(r,base,()=>false).preview
24
+ commitImport({...r,expectedHash:p.hash},base,()=>false)
25
+ assert.deepEqual(readFileSync(join(base,'sample-skill/assets/data.bin')),Buffer.from([0,128,255]))
26
+ assert.throws(()=>commitImport({...r,expectedHash:p.hash},base,()=>false),/同名/)
27
+ })
28
+ test('changed content and changed installation target invalidate preview',t=>{
29
+ const {root,base}=fixture(t),r=baseRequest(),p=prepareImport(r,base,()=>false).preview
30
+ assert.throws(()=>commitImport({...r,files:[file('SKILL.md',skill+'changed')],expectedHash:p.hash},base,()=>false),/发生变化/)
31
+ assert.throws(()=>commitImport({...r,expectedHash:p.hash},join(root,'other'),()=>false),/发生变化/)
32
+ assert.equal(existsSync(base),false)
33
+ })
34
+ test('path traversal, Windows ADS and reserved names are rejected',t=>{
35
+ const {base}=fixture(t)
36
+ for(const path of ['../escape','/escape','C:/escape','x\\escape','NUL.txt','x:stream','x/../escape','x.']) {
37
+ assert.throws(()=>prepareImport({...baseRequest(),files:[file('SKILL.md',skill),file(path,'bad')]},base,()=>false),/不安全/)
38
+ }
39
+ })
40
+ test('case-colliding filenames are rejected',t=>{
41
+ const {base}=fixture(t)
42
+ assert.throws(()=>prepareImport({...baseRequest(),files:[file('SKILL.md',skill),file('a.txt','a'),file('A.txt','b')]},base,()=>false),/大小写/)
43
+ })
44
+ test('bundled names and ambiguous containers are rejected',t=>{
45
+ const {base}=fixture(t)
46
+ assert.throws(()=>prepareImport(baseRequest(),base,()=>true),/内置/)
47
+ assert.throws(()=>prepareImport({...baseRequest(),files:[file('a/SKILL.md',skill),file('b/SKILL.md',skill)]},base,()=>false),/多个技能/)
48
+ })
49
+ test('single-file rule accepts UTF-8 BOM and CRLF; empty rules are refused',t=>{
50
+ const {base}=fixture(t),r={kind:'rule',level:'project',path:'unused',files:[file('sample-rule.md','\uFEFF# Rule\r\nDo it.')]}
51
+ const p=prepareImport(r,base,()=>false).preview
52
+ assert.equal(p.name,'sample-rule');assert.match(p.content,/# Rule/)
53
+ commitImport({...r,expectedHash:p.hash},base,()=>false)
54
+ assert.ok(existsSync(join(base,'sample-rule.md')))
55
+ assert.throws(()=>prepareImport({...r,files:[file('empty.md',' ')]},base,()=>false),/不能为空/)
56
+ })
57
+ test('file and directory prefix conflicts fail during preview',t=>{
58
+ const {base}=fixture(t),r={...baseRequest(),files:[file('SKILL.md',skill),file('a','file'),file('a/b','child')]}
59
+ assert.throws(()=>prepareImport(r,base,()=>false),/路径冲突/)
60
+ assert.equal(existsSync(base),false)
61
+ })
62
+ test('failed installation removes only its own incomplete directory',t=>{
63
+ const {base}=fixture(t),r=baseRequest(),p=prepareImport(r,base,()=>false).preview
64
+ mkdirSync(base);writeFileSync(join(base,'keep.txt'),'existing')
65
+ const original=fs.writeFileSync
66
+ try {
67
+ fs.writeFileSync=(path,...args)=>{if(String(path).endsWith('data.bin')) throw new Error('injected disk failure');return original(path,...args)}
68
+ syncBuiltinESMExports()
69
+ assert.throws(()=>commitImport({...r,expectedHash:p.hash},base,()=>false),/injected disk failure/)
70
+ } finally { fs.writeFileSync=original;syncBuiltinESMExports() }
71
+ assert.equal(existsSync(p.target),false);assert.deepEqual(readdirSync(base),['keep.txt']);assert.equal(readFileSync(join(base,'keep.txt'),'utf8'),'existing')
72
+ })
73
+
74
+ test('host directory scanner resolves one container child and excludes caches',t=>{
75
+ const {root,base}=fixture(t),src=join(root,'source'),nested=join(src,'sample')
76
+ mkdirSync(join(nested,'node_modules'),{recursive:true});writeFileSync(join(nested,'SKILL.md'),skill);writeFileSync(join(nested,'node_modules','ignore.txt'),'ignore')
77
+ const p=prepareImport({...baseRequest(),files:[],sourceDir:src},base,()=>false).preview
78
+ assert.equal(p.files,1)
79
+ })
80
+ test('host scanner rejects symlink or junction source paths',t=>{
81
+ const {root,base}=fixture(t),src=join(root,'source'),link=join(root,'link')
82
+ mkdirSync(src);writeFileSync(join(src,'SKILL.md'),skill)
83
+ symlinkSync(src,link,process.platform==='win32'?'junction':'dir')
84
+ assert.throws(()=>prepareImport({...baseRequest(),files:[],sourceDir:link},base,()=>false),/符号链接/)
85
+ })
86
+ test('deep directories and oversized files fail before installation',t=>{
87
+ const {base}=fixture(t)
88
+ assert.throws(()=>prepareImport({...baseRequest(),files:[file('SKILL.md',skill),file('a/'.repeat(21)+'b','x')]},base,()=>false),/20 层/)
89
+ assert.throws(()=>prepareImport({...baseRequest(),files:[file('SKILL.md',skill),file('big',Buffer.alloc(20*1024*1024+1))]},base,()=>false),/大小限制|20 MB/)
90
+ assert.equal(existsSync(base),false)
91
+ })
92
+ test('invalid UTF-8, encoding and absent source directories report errors',t=>{
93
+ const {root,base}=fixture(t)
94
+ assert.throws(()=>prepareImport({...baseRequest(),files:[{path:'SKILL.md',base64:'not base64!!'}]},base,()=>false),/编码/)
95
+ assert.throws(()=>prepareImport({kind:'rule',level:'user',path:'unused',files:[file('rule.md',Buffer.from([255]))]},base,()=>false))
96
+ assert.throws(()=>prepareImport({...baseRequest(),files:[],sourceDir:join(root,'missing')},base,()=>false))
97
+ })
98
+
99
+ test('registered host workspace wins over client spelling; unknown paths fail', async () => {
100
+ const {authorizedWorkspace}=await import('./lib/workspace-access.js')
101
+ const host={get:()=>({resolveByPath:async path=>path==='known'?{path:'/canonical/project'}:undefined})}
102
+ assert.equal(await authorizedWorkspace(host,'known',()=>{throw new Error('fallback must not run')}),'/canonical/project')
103
+ await assert.rejects(()=>authorizedWorkspace(host,'unknown',()=>'/unsafe'),/未在 Harness 注册/)
104
+ await assert.rejects(()=>authorizedWorkspace(host,'',()=>'/unsafe'),/请选择/)
105
+ })
106
+ test('legacy host retains its explicit workspace policy', async () => {
107
+ const {authorizedWorkspace}=await import('./lib/workspace-access.js')
108
+ assert.equal(await authorizedWorkspace({get:()=>undefined},'/project',p=>p),'/project')
109
+ await assert.rejects(()=>authorizedWorkspace({get:()=>undefined},'/bad',()=>{throw new Error('denied')}),/denied/)
110
+ })
111
+ test('host registry failures are not downgraded to legacy permissions', async () => {
112
+ const {authorizedWorkspace}=await import('./lib/workspace-access.js')
113
+ await assert.rejects(()=>authorizedWorkspace({get:()=>({resolveByPath:async()=>{throw new Error('registry unavailable')}})},'/project',()=>'/unsafe'),/registry unavailable/)
114
+ })
115
+
116
+ test('ledger shows absent storage as empty and propagates access errors',async()=>{
117
+ const {default:Controller}=await import('./lib/controller.js')
118
+ const controller=Object.create(Controller.prototype)
119
+ controller.authorizedPath=async p=>p
120
+ controller.fs=()=>({resolve:async p=>p,listDir:async()=>{throw Object.assign(new Error('missing'),{code:'ENOENT'})}})
121
+ assert.deepEqual(await controller.readTasks('/project'),{tasks:[]})
122
+ controller.fs=()=>({resolve:async p=>p,listDir:async()=>{throw Object.assign(new Error('denied'),{code:'EACCES'})}})
123
+ await assert.rejects(()=>controller.readTasks('/project'),/denied/)
124
+ })
125
+ test('ledger rejects malformed records instead of pretending there are no tasks',async()=>{
126
+ const {default:Controller}=await import('./lib/controller.js')
127
+ const controller=Object.create(Controller.prototype)
128
+ controller.authorizedPath=async p=>p
129
+ for(const content of ['{broken','null','{}']) {
130
+ controller.fs=()=>({resolve:async p=>p,listDir:async()=>[{name:'task-broken.json'}],readText:async()=>content})
131
+ await assert.rejects(()=>controller.readTasks('/project'),/任务记录/)
132
+ }
133
+ })
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 godv61
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.