@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 +19 -33
- package/.resource-test.mjs +133 -0
- package/LICENSE +21 -0
- package/README.md +216 -207
- package/docs/listing/godv61__dsh-task-engine.yml +6 -0
- package/docs/listing/submission.md +9 -0
- package/docs/manual.html +385 -382
- package/docs/release-0.23.0.md +34 -0
- package/docs/resource-install.md +18 -0
- package/docs/testing/0.23.0//346/265/213/350/257/225/346/211/247/350/241/214/350/256/260/345/275/225.md +189 -0
- package/docs/testing/0.23.0//346/265/213/350/257/225/346/212/245/345/221/212.md +42 -0
- package/lib/client.js +540 -854
- package/lib/client.js.map +4 -4
- package/lib/controller.d.ts +19 -0
- package/lib/controller.js +81 -122
- package/lib/controller.js.map +1 -1
- package/lib/dev-task.js +2 -1
- package/lib/dev-task.js.map +1 -1
- package/lib/engine.d.ts +2 -0
- package/lib/engine.js.map +1 -1
- package/lib/resource-import.d.ts +21 -0
- package/lib/resource-import.js +210 -0
- package/lib/resource-import.js.map +1 -0
- package/lib/resource-types.d.ts +25 -0
- package/lib/resource-types.js +2 -0
- package/lib/resource-types.js.map +1 -0
- package/lib/workspace-access.d.ts +6 -0
- package/lib/workspace-access.js +13 -0
- package/lib/workspace-access.js.map +1 -0
- package/package.json +94 -85
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
|
-
//
|
|
713
|
+
// A second writer between content read and write must not be silently overwritten.
|
|
727
714
|
{
|
|
728
|
-
const
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
}
|
|
743
|
-
{
|
|
744
|
-
|
|
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.
|